diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index abe11b305..6848d33fa 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -22,6 +22,7 @@ requires java.compiler; exports org.cryptomator.cryptofs; + exports org.cryptomator.cryptofs.event; exports org.cryptomator.cryptofs.common; exports org.cryptomator.cryptofs.health.api; exports org.cryptomator.cryptofs.migration; diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 905e508d3..031618cbe 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -402,7 +402,7 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti Files.createDirectories(ciphertextPath.getRawPath()); // suppresses FileAlreadyExists } - FileChannel ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options, attrs); // might throw FileAlreadyExists + FileChannel ch = openCryptoFiles.getOrCreate(cleartextFilePath, ciphertextFilePath).newFileChannel(options, attrs); // might throw FileAlreadyExists try { if (options.writable()) { ciphertextPath.persistLongFileName(); @@ -588,7 +588,7 @@ private void moveSymlink(CryptoPath cleartextSource, CryptoPath cleartextTarget, // "the symbolic link itself, not the target of the link, is moved" CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); - try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), cleartextTarget, ciphertextTarget.getRawPath())) { Files.move(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath(), options); if (ciphertextTarget.isShortened()) { ciphertextTarget.persistLongFileName(); @@ -604,7 +604,7 @@ private void moveFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, Co // we need to re-map the OpenCryptoFile entry. CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); - try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), cleartextTarget, ciphertextTarget.getRawPath())) { if (ciphertextTarget.isShortened()) { Files.createDirectories(ciphertextTarget.getRawPath()); ciphertextTarget.persistLongFileName(); diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index eacc19722..376d300eb 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -10,6 +10,7 @@ import org.cryptomator.cryptofs.attr.AttributeComponent; import org.cryptomator.cryptofs.attr.AttributeViewComponent; import org.cryptomator.cryptofs.dir.DirectoryStreamComponent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptofs.fh.OpenCryptoFileComponent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,6 +20,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; +import java.util.function.Consumer; @Module(subcomponents = {AttributeComponent.class, AttributeViewComponent.class, OpenCryptoFileComponent.class, DirectoryStreamComponent.class}) class CryptoFileSystemModule { @@ -35,4 +37,10 @@ public Optional provideNativeFileStore(@PathToVault Path pathToVault) return Optional.empty(); } } + + @Provides + @CryptoFileSystemScoped + public Consumer provideFilesystemEventConsumer(CryptoFileSystemProperties fsProps) { + return (Consumer) fsProps.get(CryptoFileSystemProperties.PROPERTY_NOTIFY_METHOD); + } } diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java index fb79a344c..ec5a12e7c 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java @@ -9,6 +9,7 @@ package org.cryptomator.cryptofs; import com.google.common.base.Strings; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.CryptorProvider; import org.cryptomator.cryptolib.api.MasterkeyLoader; @@ -80,6 +81,15 @@ public class CryptoFileSystemProperties extends AbstractMap { static final String DEFAULT_MASTERKEY_FILENAME = "masterkey.cryptomator"; + /** + * Key identifying the function to call for notifications. + * + * @since 2.9.0 + */ + public static final String PROPERTY_NOTIFY_METHOD = "notificationConsumer"; + + static final Consumer DEFAULT_NOTIFY_METHOD = (FilesystemEvent e) -> {}; + /** * Key identifying the filesystem flags. * @@ -113,6 +123,7 @@ private CryptoFileSystemProperties(Builder builder) { Map.entry(PROPERTY_FILESYSTEM_FLAGS, builder.flags), // Map.entry(PROPERTY_VAULTCONFIG_FILENAME, builder.vaultConfigFilename), // Map.entry(PROPERTY_MASTERKEY_FILENAME, builder.masterkeyFilename), // + Map.entry(PROPERTY_NOTIFY_METHOD, builder.eventConsumer), // Map.entry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, builder.maxCleartextNameLength), // Map.entry(PROPERTY_SHORTENING_THRESHOLD, builder.shorteningThreshold), // Map.entry(PROPERTY_CIPHER_COMBO, builder.cipherCombo) // @@ -208,6 +219,7 @@ public static class Builder { private String masterkeyFilename = DEFAULT_MASTERKEY_FILENAME; private int maxCleartextNameLength = DEFAULT_MAX_CLEARTEXT_NAME_LENGTH; private int shorteningThreshold = DEFAULT_SHORTENING_THRESHOLD; + private Consumer eventConsumer = DEFAULT_NOTIFY_METHOD; private Builder() { } @@ -220,6 +232,7 @@ private Builder(Map properties) { checkedSet(Integer.class, PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, properties, this::withMaxCleartextNameLength); checkedSet(Integer.class, PROPERTY_SHORTENING_THRESHOLD, properties, this::withShorteningThreshold); checkedSet(CryptorProvider.Scheme.class, PROPERTY_CIPHER_COMBO, properties, this::withCipherCombo); + checkedSet(Consumer.class, PROPERTY_NOTIFY_METHOD, properties, this::withFilesystemEventConsumer); } private void checkedSet(Class type, String key, Map properties, Consumer setter) { @@ -334,6 +347,18 @@ public Builder withMasterkeyFilename(String masterkeyFilename) { return this; } + /** + * Sets the consumer for filesystem events + * + * @param eventConsumer the consumer to receive filesystem events + * @return this + * @since 2.8.0 + */ + public Builder withFilesystemEventConsumer(Consumer eventConsumer) { + this.eventConsumer = eventConsumer; + return this; + } + /** * Validates the values and creates new {@link CryptoFileSystemProperties}. * diff --git a/src/main/java/org/cryptomator/cryptofs/Symlinks.java b/src/main/java/org/cryptomator/cryptofs/Symlinks.java index 4f2de64f4..1b45f22a1 100644 --- a/src/main/java/org/cryptomator/cryptofs/Symlinks.java +++ b/src/main/java/org/cryptomator/cryptofs/Symlinks.java @@ -27,14 +27,12 @@ public class Symlinks { private final CryptoPathMapper cryptoPathMapper; - private final LongFileNameProvider longFileNameProvider; private final OpenCryptoFiles openCryptoFiles; private final ReadonlyFlag readonlyFlag; @Inject - Symlinks(CryptoPathMapper cryptoPathMapper, LongFileNameProvider longFileNameProvider, OpenCryptoFiles openCryptoFiles, ReadonlyFlag readonlyFlag) { + Symlinks(CryptoPathMapper cryptoPathMapper, OpenCryptoFiles openCryptoFiles, ReadonlyFlag readonlyFlag) { this.cryptoPathMapper = cryptoPathMapper; - this.longFileNameProvider = longFileNameProvider; this.openCryptoFiles = openCryptoFiles; this.readonlyFlag = readonlyFlag; } @@ -48,7 +46,7 @@ public void createSymbolicLink(CryptoPath cleartextPath, Path target, FileAttrib EffectiveOpenOptions openOptions = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW), readonlyFlag); ByteBuffer content = UTF_8.encode(target.toString()); Files.createDirectory(ciphertextFilePath.getRawPath()); - openCryptoFiles.writeCiphertextFile(ciphertextFilePath.getSymlinkFilePath(), openOptions, content); + openCryptoFiles.writeCiphertextFile(cleartextPath, ciphertextFilePath.getSymlinkFilePath(), openOptions, content); ciphertextFilePath.persistLongFileName(); } @@ -57,7 +55,7 @@ public CryptoPath readSymbolicLink(CryptoPath cleartextPath) throws IOException EffectiveOpenOptions openOptions = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.READ), readonlyFlag); assertIsSymlink(cleartextPath, ciphertextSymlinkFile); try { - ByteBuffer content = openCryptoFiles.readCiphertextFile(ciphertextSymlinkFile, openOptions, Constants.MAX_SYMLINK_LENGTH); + ByteBuffer content = openCryptoFiles.readCiphertextFile(cleartextPath, ciphertextSymlinkFile, openOptions, Constants.MAX_SYMLINK_LENGTH); return cleartextPath.getFileSystem().getPath(UTF_8.decode(content).toString()); } catch (BufferUnderflowException e) { throw new NotLinkException(cleartextPath.toString(), null, "Unreasonably large symlink file"); diff --git a/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java b/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java index f40a34d72..9ad484c7a 100644 --- a/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java +++ b/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java @@ -7,7 +7,8 @@ import org.cryptomator.cryptofs.fh.BufferPool; import org.cryptomator.cryptofs.fh.Chunk; import org.cryptomator.cryptofs.fh.ChunkCache; -import org.cryptomator.cryptofs.fh.CurrentOpenFilePath; +import org.cryptomator.cryptofs.fh.ClearAndCipherPath; +import org.cryptomator.cryptofs.fh.CurrentOpenFilePaths; import org.cryptomator.cryptofs.fh.ExceptionsDuringWrite; import org.cryptomator.cryptofs.fh.FileHeaderHolder; import org.cryptomator.cryptofs.fh.OpenFileModifiedDate; @@ -25,7 +26,6 @@ import java.nio.channels.NonReadableChannelException; import java.nio.channels.NonWritableChannelException; import java.nio.file.NoSuchFileException; -import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.FileTime; import java.time.Instant; @@ -50,7 +50,7 @@ public class CleartextFileChannel extends AbstractFileChannel { private final ChunkCache chunkCache; private final BufferPool bufferPool; private final EffectiveOpenOptions options; - private final AtomicReference currentFilePath; + private final AtomicReference currentFilePaths; private final AtomicLong fileSize; private final AtomicReference lastModified; private final ExceptionsDuringWrite exceptionsDuringWrite; @@ -58,7 +58,7 @@ public class CleartextFileChannel extends AbstractFileChannel { private final CryptoFileSystemStats stats; @Inject - public CleartextFileChannel(FileChannel ciphertextFileChannel, FileHeaderHolder fileHeaderHolder, ReadWriteLock readWriteLock, Cryptor cryptor, ChunkCache chunkCache, BufferPool bufferPool, EffectiveOpenOptions options, @OpenFileSize AtomicLong fileSize, @OpenFileModifiedDate AtomicReference lastModified, @CurrentOpenFilePath AtomicReference currentPath, ExceptionsDuringWrite exceptionsDuringWrite, Consumer closeListener, CryptoFileSystemStats stats) { + public CleartextFileChannel(FileChannel ciphertextFileChannel, FileHeaderHolder fileHeaderHolder, ReadWriteLock readWriteLock, Cryptor cryptor, ChunkCache chunkCache, BufferPool bufferPool, EffectiveOpenOptions options, @OpenFileSize AtomicLong fileSize, @OpenFileModifiedDate AtomicReference lastModified, @CurrentOpenFilePaths AtomicReference currentPaths, ExceptionsDuringWrite exceptionsDuringWrite, Consumer closeListener, CryptoFileSystemStats stats) { super(readWriteLock); this.ciphertextFileChannel = ciphertextFileChannel; this.fileHeaderHolder = fileHeaderHolder; @@ -66,7 +66,7 @@ public CleartextFileChannel(FileChannel ciphertextFileChannel, FileHeaderHolder this.chunkCache = chunkCache; this.bufferPool = bufferPool; this.options = options; - this.currentFilePath = currentPath; + this.currentFilePaths = currentPaths; this.fileSize = fileSize; this.lastModified = lastModified; this.exceptionsDuringWrite = exceptionsDuringWrite; @@ -254,10 +254,11 @@ void flush() throws IOException { void persistLastModified() throws IOException { FileTime lastModifiedTime = isWritable() ? FileTime.from(lastModified.get()) : null; FileTime lastAccessTime = FileTime.from(Instant.now()); - var p = currentFilePath.get(); - if (p != null) { - p.getFileSystem().provider()// - .getFileAttributeView(p, BasicFileAttributeView.class) + var ps = currentFilePaths.get(); + if (ps != null) { + var ciphertextPath = ps.ciphertextPath(); + ciphertextPath.getFileSystem().provider()// + .getFileAttributeView(ciphertextPath, BasicFileAttributeView.class) .setTimes(lastModifiedTime, lastAccessTime, null); } diff --git a/src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java b/src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java index 4a7db9464..0f86fd790 100644 --- a/src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java +++ b/src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java @@ -6,6 +6,9 @@ import com.google.common.io.RecursiveDeleteOption; import org.cryptomator.cryptofs.VaultConfig; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent; +import org.cryptomator.cryptofs.event.ConflictResolvedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,6 +21,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.function.Consumer; import java.util.stream.Stream; import static org.cryptomator.cryptofs.common.Constants.DIR_FILE_NAME; @@ -33,14 +37,18 @@ class C9rConflictResolver { private final Cryptor cryptor; private final byte[] dirId; private final int maxC9rFileNameLength; + private final Path cleartextPath; private final int maxCleartextFileNameLength; + private final Consumer eventConsumer; @Inject - public C9rConflictResolver(Cryptor cryptor, @Named("dirId") String dirId, VaultConfig vaultConfig) { + public C9rConflictResolver(Cryptor cryptor, @Named("dirId") String dirId, VaultConfig vaultConfig, Consumer eventConsumer, @Named("cleartextPath") Path cleartextPath) { this.cryptor = cryptor; this.dirId = dirId.getBytes(StandardCharsets.US_ASCII); this.maxC9rFileNameLength = vaultConfig.getShorteningThreshold(); + this.cleartextPath = cleartextPath; this.maxCleartextFileNameLength = (maxC9rFileNameLength - 4) / 4 * 3 - 16; // math from FileSystemCapabilityChecker.determineSupportedCleartextFileNameLength() + this.eventConsumer = eventConsumer; } public Stream process(Node node) { @@ -61,13 +69,15 @@ public Stream process(Node node) { Path canonicalPath = node.ciphertextPath.resolveSibling(canonicalCiphertextFileName); return resolveConflict(node, canonicalPath); } catch (IOException e) { - LOG.error("Failed to resolve conflict for " + node.ciphertextPath, e); + eventConsumer.accept(new ConflictResolutionFailedEvent(cleartextPath.resolve(node.cleartextName), node.ciphertextPath.resolve(node.fullCiphertextFileName), e)); + LOG.error("Failed to resolve conflict for {}", node.ciphertextPath, e); return Stream.empty(); } } } - private Stream resolveConflict(Node conflicting, Path canonicalPath) throws IOException { + //visible for testing + Stream resolveConflict(Node conflicting, Path canonicalPath) throws IOException { Path conflictingPath = conflicting.ciphertextPath; if (resolveConflictTrivially(canonicalPath, conflictingPath)) { Node resolved = new Node(canonicalPath); @@ -111,6 +121,7 @@ private Node renameConflictingFile(Path canonicalPath, Path conflictingPath, Str Node node = new Node(alternativePath); node.cleartextName = alternativeCleartext; node.extractedCiphertext = alternativeCiphertext; + eventConsumer.accept(new ConflictResolvedEvent(cleartextPath.resolve(cleartext), canonicalPath, cleartextPath.resolve(alternativeCleartext), alternativePath)); return node; } diff --git a/src/main/java/org/cryptomator/cryptofs/event/ConflictResolutionFailedEvent.java b/src/main/java/org/cryptomator/cryptofs/event/ConflictResolutionFailedEvent.java new file mode 100644 index 000000000..4a7af7ff2 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/ConflictResolutionFailedEvent.java @@ -0,0 +1,14 @@ +package org.cryptomator.cryptofs.event; + +import java.nio.file.Path; + +/** + * Emitted, if the conflict resolution inside an encrypted directory failed + * + * @param cleartextPath path within the cryptographic filesystem + * @param ciphertextPath path to the encrypted resource with the broken filename + * @param reason exception, why the resolution failed + */ +public record ConflictResolutionFailedEvent(Path cleartextPath, Path ciphertextPath, Exception reason) implements FilesystemEvent { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/event/ConflictResolvedEvent.java b/src/main/java/org/cryptomator/cryptofs/event/ConflictResolvedEvent.java new file mode 100644 index 000000000..26e54d54a --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/ConflictResolvedEvent.java @@ -0,0 +1,15 @@ +package org.cryptomator.cryptofs.event; + +import java.nio.file.Path; + +/** + * Emitted, if a conflict inside an encrypted directory was resolved + * + * @param cleartextPath path within the cryptographic filesystem + * @param ciphertextPath path to the encrypted resource + * @param oldVersionCleartextPath path within the cryptographic filesystem of the renamed resource + * @param oldVersionCiphertextPath path to the renamed, encrypted resource + */ +public record ConflictResolvedEvent(Path cleartextPath, Path ciphertextPath, Path oldVersionCleartextPath, Path oldVersionCiphertextPath) implements FilesystemEvent { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/event/DecryptionFailedEvent.java b/src/main/java/org/cryptomator/cryptofs/event/DecryptionFailedEvent.java new file mode 100644 index 000000000..199197db9 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/DecryptionFailedEvent.java @@ -0,0 +1,16 @@ +package org.cryptomator.cryptofs.event; + +import org.cryptomator.cryptolib.api.AuthenticationFailedException; + +import java.nio.file.Path; + +/** + * Emitted, if a decryption operation fails. + * + * @param cleartextPath path within the cryptographic filesystem + * @param ciphertextPath path to the encrypted resource + * @param e thrown exception + */ +public record DecryptionFailedEvent(Path cleartextPath, Path ciphertextPath, AuthenticationFailedException e) implements FilesystemEvent { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java b/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java new file mode 100644 index 000000000..a2d7fb090 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java @@ -0,0 +1,27 @@ +package org.cryptomator.cryptofs.event; + +import java.util.function.Consumer; + +/** + * Common interface for all filesystem events. + *

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

+ * To get a specific event type, use the enhanced switch pattern or typecasting in if-instance of, e.g. + * {@code + * FilesystemEvent fse; + * switch (fse) { + * case DecryptionFailedEvent dfe -> //do stuff + * case ConflictResolvedEvent cre -> //do other stuff + * //other cases + * } + * if( fse instanceof DecryptionFailedEvent dfe) { + * //do more stuff + * } + * }. + * + * @apiNote Events might have occured a long time ago in a galaxy far, far away... therefore, any feedback method is non-blocking and might fail due to changes in the filesystem. + */ +public sealed interface FilesystemEvent permits ConflictResolutionFailedEvent, ConflictResolvedEvent, DecryptionFailedEvent { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java b/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java index e2fafb045..e08db91fa 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java @@ -1,16 +1,22 @@ package org.cryptomator.cryptofs.fh; import org.cryptomator.cryptofs.CryptoFileSystemStats; +import org.cryptomator.cryptofs.event.DecryptionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; import javax.inject.Inject; import java.io.IOException; import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; @OpenFileScoped class ChunkLoader { + private final Consumer eventConsumer; + private final AtomicReference paths; private final Cryptor cryptor; private final ChunkIO ciphertext; private final FileHeaderHolder headerHolder; @@ -18,7 +24,9 @@ class ChunkLoader { private final BufferPool bufferPool; @Inject - public ChunkLoader(Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) { + public ChunkLoader(Consumer eventConsumer, @CurrentOpenFilePaths AtomicReference paths, Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) { + this.eventConsumer = eventConsumer; + this.paths = paths; this.cryptor = cryptor; this.ciphertext = ciphertext; this.headerHolder = headerHolder; @@ -42,6 +50,10 @@ public ByteBuffer load(Long chunkIndex) throws IOException, AuthenticationFailed stats.addBytesDecrypted(cleartextBuf.remaining()); } return cleartextBuf; + } catch (AuthenticationFailedException e) { + var tmp = paths.get(); + eventConsumer.accept(new DecryptionFailedEvent(tmp.cleartextPath(), tmp.ciphertextPath(), e)); + throw e; } finally { bufferPool.recycle(ciphertextBuf); } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/ClearAndCipherPath.java b/src/main/java/org/cryptomator/cryptofs/fh/ClearAndCipherPath.java new file mode 100644 index 000000000..7a0a92b31 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/fh/ClearAndCipherPath.java @@ -0,0 +1,9 @@ +package org.cryptomator.cryptofs.fh; + +import org.cryptomator.cryptofs.CryptoPath; + +import java.nio.file.Path; + +public record ClearAndCipherPath(CryptoPath cleartextPath, Path ciphertextPath) { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFilePath.java b/src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFilePaths.java similarity index 81% rename from src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFilePath.java rename to src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFilePaths.java index 5a8f765d5..f31ad6bf7 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFilePath.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFilePaths.java @@ -8,10 +8,10 @@ /** * The current Path of an OpenCryptoFile. - * @see OriginalOpenFilePath + * @see OriginalOpenFilePaths */ @Qualifier @Documented @Retention(RUNTIME) -public @interface CurrentOpenFilePath { +public @interface CurrentOpenFilePaths { } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java b/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java index 822b8740d..3a56222e5 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java @@ -1,5 +1,8 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.event.DecryptionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileHeader; @@ -10,25 +13,27 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; -import java.nio.file.Path; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; @OpenFileScoped public class FileHeaderHolder { private static final Logger LOG = LoggerFactory.getLogger(FileHeaderHolder.class); + private final Consumer eventConsumer; private final Cryptor cryptor; - private final AtomicReference path; + private final AtomicReference paths; private final AtomicReference header = new AtomicReference<>(); private final AtomicReference encryptedHeader = new AtomicReference<>(); private final AtomicBoolean isPersisted = new AtomicBoolean(); @Inject - public FileHeaderHolder(Cryptor cryptor, @CurrentOpenFilePath AtomicReference path) { + public FileHeaderHolder(Consumer eventConsumer, Cryptor cryptor, @CurrentOpenFilePaths AtomicReference paths) { + this.eventConsumer = eventConsumer; this.cryptor = cryptor; - this.path = path; + this.paths = paths; } public FileHeader get() { @@ -48,7 +53,7 @@ public ByteBuffer getEncrypted() { } FileHeader createNew() { - LOG.trace("Generating file header for {}", path.get()); + LOG.trace("Generating file header for {}", paths.get().ciphertextPath()); FileHeader newHeader = cryptor.fileHeaderCryptor().create(); encryptedHeader.set(cryptor.fileHeaderCryptor().encryptHeader(newHeader).asReadOnlyBuffer()); //to prevent NONCE reuse, we already encrypt the header and cache it header.set(newHeader); @@ -64,7 +69,7 @@ FileHeader createNew() { * @throws IOException if the file header cannot be read or decrypted */ FileHeader loadExisting(FileChannel ch) throws IOException { - LOG.trace("Reading file header from {}", path.get()); + LOG.trace("Reading file header from {}", paths.get().cleartextPath()); ByteBuffer existingHeaderBuf = ByteBuffer.allocate(cryptor.fileHeaderCryptor().headerSize()); ch.read(existingHeaderBuf, 0); existingHeaderBuf.flip(); @@ -75,7 +80,11 @@ FileHeader loadExisting(FileChannel ch) throws IOException { isPersisted.set(true); return existingHeader; } catch (IllegalArgumentException | CryptoException e) { - throw new IOException("Unable to decrypt header of file " + path.get(), e); + var ps = paths.get(); + if (e instanceof AuthenticationFailedException afe) { + eventConsumer.accept(new DecryptionFailedEvent(ps.cleartextPath(), ps.ciphertextPath(), afe)); + } + throw new IOException("Unable to decrypt header of file " + ps.ciphertextPath(), e); } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index ca5072910..53e656f1d 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -10,7 +10,6 @@ import java.io.Closeable; import java.io.IOException; import java.nio.channels.FileChannel; -import java.nio.file.Path; import java.nio.file.attribute.FileAttribute; import java.nio.file.attribute.FileTime; import java.time.Instant; @@ -29,7 +28,7 @@ public class OpenCryptoFile implements Closeable { private final Cryptor cryptor; private final FileHeaderHolder headerHolder; private final ChunkIO chunkIO; - private final AtomicReference currentFilePath; + private final AtomicReference currentFilePaths; private final AtomicLong fileSize; private final OpenCryptoFileComponent component; @@ -37,13 +36,13 @@ public class OpenCryptoFile implements Closeable { @Inject public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHolder headerHolder, ChunkIO chunkIO, // - @CurrentOpenFilePath AtomicReference currentFilePath, @OpenFileSize AtomicLong fileSize, // + @CurrentOpenFilePaths AtomicReference currentFilePaths, @OpenFileSize AtomicLong fileSize, // @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component) { this.listener = listener; this.cryptor = cryptor; this.headerHolder = headerHolder; this.chunkIO = chunkIO; - this.currentFilePath = currentFilePath; + this.currentFilePaths = currentFilePaths; this.fileSize = fileSize; this.component = component; this.lastModified = lastModified; @@ -57,8 +56,8 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol * @throws IOException */ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, FileAttribute... attrs) throws IOException { - Path path = currentFilePath.get(); - if (path == null) { + var paths = currentFilePaths.get(); + if (paths == null) { throw new IllegalStateException("Cannot create file channel to deleted file"); } FileChannel ciphertextFileChannel = null; @@ -66,7 +65,8 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { - ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); + var ciphertextPath = paths.ciphertextPath(); + ciphertextFileChannel = ciphertextPath.getFileSystem().provider().newFileChannel(ciphertextPath, options.createOpenOptionsForEncryptedFile(), attrs); initFileHeader(options, ciphertextFileChannel); initFileSize(ciphertextFileChannel); cleartextFileChannel = component.newChannelComponent() // @@ -159,16 +159,17 @@ public void setLastModifiedTime(FileTime lastModifiedTime) { lastModified.set(lastModifiedTime.toInstant()); } - public Path getCurrentFilePath() { - return currentFilePath.get(); + public ClearAndCipherPath getCurrentFilePaths() { + return currentFilePaths.get(); } /** * Updates the current ciphertext file path, if it is not already set to null (i.e., the openCryptoFile is deleted) - * @param newFilePath new ciphertext path + * + * @param newPaths the new clear- & ciphertext paths */ - public void updateCurrentFilePath(Path newFilePath) { - currentFilePath.updateAndGet(p -> p == null ? null : newFilePath); + public void updateCurrentFilePath(ClearAndCipherPath newPaths) { + currentFilePaths.updateAndGet(p -> p == null ? null : newPaths); } private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChannel) { @@ -182,14 +183,15 @@ private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChann @Override public void close() { - var p = currentFilePath.get(); - if(p != null) { - listener.close(p, this); + var p = currentFilePaths.get(); + if (p != null) { + listener.close(p.ciphertextPath(), this); } } @Override public String toString() { - return "OpenCryptoFile(path=" + currentFilePath.toString() + ")"; + var paths = currentFilePaths.get(); + return "OpenCryptoFile(path=" + (paths != null ? paths.ciphertextPath().toString() : "[deleted]") + ")"; } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileComponent.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileComponent.java index 9db160249..be8f06ce1 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileComponent.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileComponent.java @@ -17,7 +17,7 @@ public interface OpenCryptoFileComponent { @Subcomponent.Factory interface Factory { - OpenCryptoFileComponent create(@BindsInstance @OriginalOpenFilePath Path path, // + OpenCryptoFileComponent create(@BindsInstance @OriginalOpenFilePaths ClearAndCipherPath clearAndCipherPath, // @BindsInstance FileCloseListener onCloseListener); } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileModule.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileModule.java index aceb74842..3c43b0574 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileModule.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileModule.java @@ -6,7 +6,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; import java.time.Instant; @@ -15,7 +14,6 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.function.Supplier; @Module public class OpenCryptoFileModule { @@ -28,16 +26,16 @@ public ReadWriteLock provideReadWriteLock() { @Provides @OpenFileScoped - @CurrentOpenFilePath - public AtomicReference provideCurrentPath(@OriginalOpenFilePath Path originalPath) { - return new AtomicReference<>(originalPath); + @CurrentOpenFilePaths + public AtomicReference provideCurrentPaths(@OriginalOpenFilePaths ClearAndCipherPath paths) { + return new AtomicReference<>(paths); } @Provides @OpenFileScoped @OpenFileModifiedDate - public AtomicReference provideLastModifiedDate(@OriginalOpenFilePath Path originalPath) { - Instant lastModifiedDate = readBasicAttributes(originalPath).map(BasicFileAttributes::lastModifiedTime).map(FileTime::toInstant).orElse(Instant.EPOCH); + public AtomicReference provideLastModifiedDate(@OriginalOpenFilePaths ClearAndCipherPath originalPaths) { + Instant lastModifiedDate = readBasicAttributes(originalPaths.ciphertextPath()).map(BasicFileAttributes::lastModifiedTime).map(FileTime::toInstant).orElse(Instant.EPOCH); return new AtomicReference<>(lastModifiedDate); } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java index 4e8722507..64c9f497e 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java @@ -9,6 +9,7 @@ package org.cryptomator.cryptofs.fh; import org.cryptomator.cryptofs.CryptoFileSystemScoped; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; import javax.inject.Inject; @@ -54,23 +55,24 @@ public Optional get(Path ciphertextPath) { * Opens a file to {@link OpenCryptoFile#newFileChannel(EffectiveOpenOptions, java.nio.file.attribute.FileAttribute[]) retrieve a FileChannel}. If this file is already opened, a shared instance is returned. * Getting the file channel should be the next invocation, since the {@link OpenFileScoped lifecycle} of the OpenFile strictly depends on the lifecycle of the channel. * + * @param cleartextPath Cleartext path * @param ciphertextPath Path of the file to open * @return The opened file. * @see #get(Path) */ - public OpenCryptoFile getOrCreate(Path ciphertextPath) { + public OpenCryptoFile getOrCreate(CryptoPath cleartextPath, Path ciphertextPath) { Path normalizedPath = ciphertextPath.toAbsolutePath().normalize(); - return openCryptoFiles.computeIfAbsent(normalizedPath, p -> openCryptoFileComponentFactory.create(p, openCryptoFiles::remove).openCryptoFile()); // computeIfAbsent is atomic, "create" is called at most once + return openCryptoFiles.computeIfAbsent(normalizedPath, p -> openCryptoFileComponentFactory.create(new ClearAndCipherPath(cleartextPath, ciphertextPath), openCryptoFiles::remove).openCryptoFile()); // computeIfAbsent is atomic, "create" is called at most once } - public void writeCiphertextFile(Path ciphertextPath, EffectiveOpenOptions openOptions, ByteBuffer contents) throws IOException { - try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { + public void writeCiphertextFile(CryptoPath cleartextPath, Path ciphertextPath, EffectiveOpenOptions openOptions, ByteBuffer contents) throws IOException { + try (OpenCryptoFile f = getOrCreate(cleartextPath, ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { ch.write(contents); } } - public ByteBuffer readCiphertextFile(Path ciphertextPath, EffectiveOpenOptions openOptions, int maxBufferSize) throws BufferUnderflowException, IOException { - try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { + public ByteBuffer readCiphertextFile(CryptoPath cleartextPath, Path ciphertextPath, EffectiveOpenOptions openOptions, int maxBufferSize) throws BufferUnderflowException, IOException { + try (OpenCryptoFile f = getOrCreate(cleartextPath, ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { if (ch.size() > maxBufferSize) { throw new BufferUnderflowException(); } @@ -99,13 +101,14 @@ public void delete(Path ciphertextPath) { * Prepares to update any open file references during a move operation. * MUST be invoked using a try-with-resource statement and committed after the physical file move succeeded. * - * @param src The ciphertext file path before the move - * @param dst The ciphertext file path after the move + * @param ciphertextSrc The ciphertext file path before the move + * @param cleartextDst The cleartext path after the move + * @param ciphertextDst The ciphertext file path after the move * @return Utility to update OpenCryptoFile references. * @throws FileAlreadyExistsException Thrown if the destination file is an existing file that is currently opened. */ - public TwoPhaseMove prepareMove(Path src, Path dst) throws FileAlreadyExistsException { - return new TwoPhaseMove(src, dst); + public TwoPhaseMove prepareMove(Path ciphertextSrc, CryptoPath cleartextDst, Path ciphertextDst) throws FileAlreadyExistsException { + return new TwoPhaseMove(ciphertextSrc, cleartextDst, ciphertextDst); } /** @@ -123,26 +126,29 @@ public void close() { public class TwoPhaseMove implements AutoCloseable { - private final Path src; - private final Path dst; + private final Path ciphertextSrc; + private final CryptoPath cleartextDst; + private final Path ciphertextDst; private final OpenCryptoFile openCryptoFile; private boolean committed; private boolean rolledBack; - private TwoPhaseMove(Path src, Path dst) throws FileAlreadyExistsException { - this.src = Objects.requireNonNull(src); - this.dst = Objects.requireNonNull(dst); + + public TwoPhaseMove(Path ciphertextSrc, CryptoPath cleartextDst, Path ciphertextDst) throws FileAlreadyExistsException { + this.ciphertextSrc = Objects.requireNonNull(ciphertextSrc); + this.cleartextDst = cleartextDst; + this.ciphertextDst = Objects.requireNonNull(ciphertextDst); try { // ConcurrentHashMap.compute is atomic: - this.openCryptoFile = openCryptoFiles.compute(dst, (k, v) -> { + this.openCryptoFile = openCryptoFiles.compute(this.ciphertextDst, (k, v) -> { if (v == null) { - return openCryptoFiles.get(src); + return openCryptoFiles.get(this.ciphertextSrc); } else { throw new AlreadyMappedException(); } }); } catch (AlreadyMappedException e) { - throw new FileAlreadyExistsException(dst.toString(), null, "Destination file currently accessed by another thread."); + throw new FileAlreadyExistsException(this.ciphertextDst.toString(), null, "Destination file currently accessed by another thread."); } } @@ -151,9 +157,9 @@ public void commit() { throw new IllegalStateException(); } if (openCryptoFile != null) { - openCryptoFile.updateCurrentFilePath(dst); + openCryptoFile.updateCurrentFilePath(new ClearAndCipherPath(cleartextDst, ciphertextDst)); } - openCryptoFiles.remove(src, openCryptoFile); + openCryptoFiles.remove(ciphertextSrc, openCryptoFile); committed = true; } @@ -161,7 +167,7 @@ public void rollback() { if (committed) { throw new IllegalStateException(); } - openCryptoFiles.remove(dst, openCryptoFile); + openCryptoFiles.remove(ciphertextDst, openCryptoFile); rolledBack = true; } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OriginalOpenFilePath.java b/src/main/java/org/cryptomator/cryptofs/fh/OriginalOpenFilePaths.java similarity index 82% rename from src/main/java/org/cryptomator/cryptofs/fh/OriginalOpenFilePath.java rename to src/main/java/org/cryptomator/cryptofs/fh/OriginalOpenFilePaths.java index 099911f89..2a3ba84da 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OriginalOpenFilePath.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OriginalOpenFilePaths.java @@ -9,10 +9,10 @@ /** * The Path used to create an OpenCryptoFile - * @see CurrentOpenFilePath + * @see CurrentOpenFilePaths */ @Qualifier @Documented @Retention(RUNTIME) -@interface OriginalOpenFilePath { +@interface OriginalOpenFilePaths { } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 5343dfc7c..4e369a26d 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -453,7 +453,7 @@ public void setup() throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextPath)).thenReturn(CiphertextFileType.FILE); when(cryptoPathMapper.getCiphertextFilePath(cleartextPath)).thenReturn(ciphertextPath); when(ciphertextPath.getFilePath()).thenReturn(ciphertextFilePath); - when(openCryptoFiles.getOrCreate(ciphertextFilePath)).thenReturn(openCryptoFile); + when(openCryptoFiles.getOrCreate(cleartextPath, ciphertextFilePath)).thenReturn(openCryptoFile); when(ciphertextFilePath.getName(3)).thenReturn(mock(CryptoPath.class, "path.c9r")); when(openCryptoFile.newFileChannel(any(), any(FileAttribute[].class))).thenReturn(fileChannel); } @@ -752,7 +752,7 @@ public void moveSymlink() throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextSource)).thenReturn(CiphertextFileType.SYMLINK); when(cryptoPathMapper.getCiphertextFileType(cleartextDestination)).thenThrow(NoSuchFileException.class); TwoPhaseMove openFileMove = Mockito.mock(TwoPhaseMove.class); - Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, ciphertextDestinationFile)).thenReturn(openFileMove); + Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, cleartextDestination, ciphertextDestinationFile)).thenReturn(openFileMove); CopyOption option1 = mock(CopyOption.class); CopyOption option2 = mock(CopyOption.class); @@ -769,7 +769,7 @@ public void moveFile() throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextSource)).thenReturn(CiphertextFileType.FILE); when(cryptoPathMapper.getCiphertextFileType(cleartextDestination)).thenThrow(NoSuchFileException.class); TwoPhaseMove openFileMove = Mockito.mock(TwoPhaseMove.class); - Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, ciphertextDestinationFile)).thenReturn(openFileMove); + Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, cleartextDestination, ciphertextDestinationFile)).thenReturn(openFileMove); CopyOption option1 = mock(CopyOption.class); CopyOption option2 = mock(CopyOption.class); diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java index 34ad89cac..9690ead3f 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java @@ -51,7 +51,8 @@ public void testSetMasterkeyFilenameAndReadonlyFlag() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, DEFAULT_MAX_CLEARTEXT_NAME_LENGTH), // anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // + anEntry(PROPERTY_NOTIFY_METHOD, DEFAULT_NOTIFY_METHOD))); } @Test @@ -77,7 +78,8 @@ public void testFromMap() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, 255), // anEntry(PROPERTY_SHORTENING_THRESHOLD, 221), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // + anEntry(PROPERTY_NOTIFY_METHOD, DEFAULT_NOTIFY_METHOD))); } @Test @@ -99,7 +101,8 @@ public void testWrapMapWithTrueReadonly() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, DEFAULT_MAX_CLEARTEXT_NAME_LENGTH), // anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // + anEntry(PROPERTY_NOTIFY_METHOD, DEFAULT_NOTIFY_METHOD))); } @Test @@ -121,7 +124,8 @@ public void testWrapMapWithFalseReadonly() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, DEFAULT_MAX_CLEARTEXT_NAME_LENGTH), // anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)))); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)), // + anEntry(PROPERTY_NOTIFY_METHOD, DEFAULT_NOTIFY_METHOD))); } @Test @@ -173,9 +177,8 @@ public void testWrapMapWithoutReadonly() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, DEFAULT_MAX_CLEARTEXT_NAME_LENGTH), // anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)) - ) - ); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)), // + anEntry(PROPERTY_NOTIFY_METHOD, DEFAULT_NOTIFY_METHOD))); } @Test diff --git a/src/test/java/org/cryptomator/cryptofs/SymlinksTest.java b/src/test/java/org/cryptomator/cryptofs/SymlinksTest.java index 5169dc64b..ad8fe9295 100644 --- a/src/test/java/org/cryptomator/cryptofs/SymlinksTest.java +++ b/src/test/java/org/cryptomator/cryptofs/SymlinksTest.java @@ -23,7 +23,6 @@ public class SymlinksTest { private final CryptoPathMapper cryptoPathMapper = Mockito.mock(CryptoPathMapper.class); - private final LongFileNameProvider longFileNameProvider = Mockito.mock(LongFileNameProvider.class); private final OpenCryptoFiles openCryptoFiles = Mockito.mock(OpenCryptoFiles.class); private final ReadonlyFlag readonlyFlag = Mockito.mock(ReadonlyFlag.class); private final FileSystem underlyingFs = Mockito.mock(FileSystem.class); @@ -33,7 +32,7 @@ public class SymlinksTest { @BeforeEach public void setup() throws IOException { - inTest = new Symlinks(cryptoPathMapper, longFileNameProvider, openCryptoFiles, readonlyFlag); + inTest = new Symlinks(cryptoPathMapper, openCryptoFiles, readonlyFlag); Mockito.when(underlyingFs.provider()).thenReturn(underlyingFsProvider); } @@ -71,7 +70,7 @@ public void testCreateSymbolicLink() throws IOException { ArgumentCaptor bytesWritten = ArgumentCaptor.forClass(ByteBuffer.class); Mockito.verify(underlyingFsProvider).createDirectory(Mockito.eq(ciphertextPath), Mockito.any(FileAttribute[].class)); - Mockito.verify(openCryptoFiles).writeCiphertextFile(Mockito.eq(symlinkFilePath), Mockito.any(), bytesWritten.capture()); + Mockito.verify(openCryptoFiles).writeCiphertextFile(Mockito.eq(cleartextPath), Mockito.eq(symlinkFilePath), Mockito.any(), bytesWritten.capture()); Assertions.assertEquals("/symlink/target/path", StandardCharsets.UTF_8.decode(bytesWritten.getValue()).toString()); } @@ -85,7 +84,7 @@ public void testReadSymbolicLink() throws IOException { CryptoPath resolvedTargetPath = Mockito.mock(CryptoPath.class, "resolvedTargetPath"); Path ciphertextPath = mockExistingSymlink(cleartextPath); Path symlinkFilePath = ciphertextPath.resolve("symlink.c9r"); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(symlinkFilePath), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode(targetPath)); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath), Mockito.eq(symlinkFilePath), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode(targetPath)); Mockito.when(cleartextFs.getPath(targetPath)).thenReturn(resolvedTargetPath); @@ -120,8 +119,8 @@ public void testResolveRecursively() throws IOException { Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath1)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath2)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath3)).thenReturn(CiphertextFileType.FILE); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath2), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file3")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath1),Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath2),Mockito.eq(ciphertextSymlinkPath2), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file3")); Mockito.when(cleartextFs.getPath("file2")).thenReturn(cleartextPath2); Mockito.when(cleartextFs.getPath("file3")).thenReturn(cleartextPath3); @@ -141,7 +140,7 @@ public void testResolveRecursivelyWithNonExistingTarget() throws IOException { Mockito.when(cleartextPath2.getFileSystem()).thenReturn(cleartextFs); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath1)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath2)).thenThrow(new NoSuchFileException("cleartextPath2")); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath1), Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); Mockito.when(cleartextFs.getPath("file2")).thenReturn(cleartextPath2); CryptoPath resolved = inTest.resolveRecursively(cleartextPath1); @@ -167,9 +166,9 @@ public void testResolveRecursivelyWithLoop() throws IOException { Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath1)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath2)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath3)).thenReturn(CiphertextFileType.SYMLINK); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath2), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file3")); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath3), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file1")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath1), Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath2), Mockito.eq(ciphertextSymlinkPath2), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file3")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath3), Mockito.eq(ciphertextSymlinkPath3), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file1")); Mockito.when(cleartextFs.getPath("file2")).thenReturn(cleartextPath2); Mockito.when(cleartextFs.getPath("file3")).thenReturn(cleartextPath3); Mockito.when(cleartextFs.getPath("file1")).thenReturn(cleartextPath1); diff --git a/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java b/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java index e203b16b0..c0b627a5b 100644 --- a/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java +++ b/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java @@ -1,10 +1,12 @@ package org.cryptomator.cryptofs.ch; import org.cryptomator.cryptofs.CryptoFileSystemStats; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.fh.BufferPool; import org.cryptomator.cryptofs.fh.Chunk; import org.cryptomator.cryptofs.fh.ChunkCache; +import org.cryptomator.cryptofs.fh.ClearAndCipherPath; import org.cryptomator.cryptofs.fh.ExceptionsDuringWrite; import org.cryptomator.cryptofs.fh.FileHeaderHolder; import org.cryptomator.cryptolib.api.Cryptor; @@ -71,8 +73,9 @@ 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 AtomicReference currentFilePath = new AtomicReference<>(filePath); + private Path cipherPath = Mockito.mock(Path.class, "/foo/bar"); + private CryptoPath clearPath = Mockito.mock(CryptoPath.class, "/clear/text"); + private AtomicReference currentFilePaths = new AtomicReference<>(new ClearAndCipherPath(clearPath, cipherPath)); private AtomicLong fileSize = new AtomicLong(100); private AtomicReference lastModified = new AtomicReference<>(Instant.ofEpochMilli(0)); private BasicFileAttributeView attributeView = mock(BasicFileAttributeView.class); @@ -96,13 +99,13 @@ public void setUp() throws IOException { when(fileContentCryptor.ciphertextChunkSize()).thenReturn(110); var fs = Mockito.mock(FileSystem.class); var fsProvider = Mockito.mock(FileSystemProvider.class); - when(filePath.getFileSystem()).thenReturn(fs); + when(cipherPath.getFileSystem()).thenReturn(fs); when(fs.provider()).thenReturn(fsProvider); - when(fsProvider.getFileAttributeView(filePath, BasicFileAttributeView.class)).thenReturn(attributeView); + when(fsProvider.getFileAttributeView(cipherPath, BasicFileAttributeView.class)).thenReturn(attributeView); when(readWriteLock.readLock()).thenReturn(readLock); when(readWriteLock.writeLock()).thenReturn(writeLock); - inTest = new CleartextFileChannel(ciphertextFileChannel, headerHolder, readWriteLock, cryptor, chunkCache, bufferPool, options, fileSize, lastModified, currentFilePath, exceptionsDuringWrite, closeListener, stats); + inTest = new CleartextFileChannel(ciphertextFileChannel, headerHolder, readWriteLock, cryptor, chunkCache, bufferPool, options, fileSize, lastModified, currentFilePaths, exceptionsDuringWrite, closeListener, stats); } @Test @@ -397,7 +400,7 @@ public void testReadFromMultipleChunks() throws IOException { fileSize.set(5_000_000_100l); // initial cleartext size will be 5_000_000_100l when(options.readable()).thenReturn(true); - inTest = new CleartextFileChannel(ciphertextFileChannel, headerHolder, readWriteLock, cryptor, chunkCache, bufferPool, options, fileSize, lastModified, currentFilePath, exceptionsDuringWrite, closeListener, stats); + inTest = new CleartextFileChannel(ciphertextFileChannel, headerHolder, readWriteLock, cryptor, chunkCache, bufferPool, options, fileSize, lastModified, currentFilePaths, exceptionsDuringWrite, closeListener, stats); ByteBuffer buf = ByteBuffer.allocate(10); // A read from frist chunk: @@ -569,7 +572,7 @@ public void testWriteHeaderFailsResetsPersistenceState() throws IOException { public void testDontRewriteHeader() throws IOException { when(options.writable()).thenReturn(true); when(headerIsPersisted.get()).thenReturn(true); - inTest = new CleartextFileChannel(ciphertextFileChannel, headerHolder, readWriteLock, cryptor, chunkCache, bufferPool, options, fileSize, lastModified, currentFilePath, exceptionsDuringWrite, closeListener, stats); + inTest = new CleartextFileChannel(ciphertextFileChannel, headerHolder, readWriteLock, cryptor, chunkCache, bufferPool, options, fileSize, lastModified, currentFilePaths, exceptionsDuringWrite, closeListener, stats); inTest.force(true); diff --git a/src/test/java/org/cryptomator/cryptofs/dir/C9rConflictResolverTest.java b/src/test/java/org/cryptomator/cryptofs/dir/C9rConflictResolverTest.java index fc868950b..d6fdc1e66 100644 --- a/src/test/java/org/cryptomator/cryptofs/dir/C9rConflictResolverTest.java +++ b/src/test/java/org/cryptomator/cryptofs/dir/C9rConflictResolverTest.java @@ -1,6 +1,9 @@ package org.cryptomator.cryptofs.dir; import org.cryptomator.cryptofs.VaultConfig; +import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent; +import org.cryptomator.cryptofs.event.ConflictResolvedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileNameCryptor; import org.junit.jupiter.api.Assertions; @@ -8,21 +11,27 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.function.Consumer; import java.util.stream.Stream; +import static org.mockito.Mockito.verify; + public class C9rConflictResolverTest { private Cryptor cryptor; private FileNameCryptor fileNameCryptor; private VaultConfig vaultConfig; + private Consumer eventConsumer = Mockito.mock(Consumer.class); + private Path cleartextPath = Mockito.mock(Path.class, "/clear/text/path/"); private C9rConflictResolver conflictResolver; @BeforeEach @@ -32,9 +41,10 @@ public void setup() { vaultConfig = Mockito.mock(VaultConfig.class); Mockito.when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); Mockito.when(vaultConfig.getShorteningThreshold()).thenReturn(44); // results in max cleartext size = 14 - conflictResolver = new C9rConflictResolver(cryptor, "foo", vaultConfig); + Mockito.when(cleartextPath.resolve(Mockito.anyString())).thenReturn(cleartextPath); + conflictResolver = new C9rConflictResolver(cryptor, "foo", vaultConfig, eventConsumer, cleartextPath); } - + @Test public void testResolveNonConflictingNode() { Node unresolved = new Node(Paths.get("foo.c9r")); @@ -75,6 +85,8 @@ public void testResolveConflictingFileByChoosingNewName(@TempDir Path dir) throw Assertions.assertEquals("bar (1).txt", resolved.cleartextName); Assertions.assertTrue(Files.exists(resolved.ciphertextPath)); Assertions.assertFalse(Files.exists(unresolved.ciphertextPath)); + var isConflictResolvedEvent = (ArgumentMatcher) ev -> ev instanceof ConflictResolvedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isConflictResolvedEvent)); } @Test @@ -94,6 +106,8 @@ public void testResolveConflictingFileByChoosingNewLengthLimitedName(@TempDir Pa Assertions.assertEquals("hello (1).txt", resolved.cleartextName); Assertions.assertTrue(Files.exists(resolved.ciphertextPath)); Assertions.assertFalse(Files.exists(unresolved.ciphertextPath)); + var isConflictResolvedEvent = (ArgumentMatcher) ev -> ev instanceof ConflictResolvedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isConflictResolvedEvent)); } @Test @@ -150,4 +164,24 @@ public void testResolveConflictingSymlinkTrivially(@TempDir Path dir) throws IOE Assertions.assertFalse(Files.exists(unresolved.ciphertextPath)); } + @Test + public void testConflictResolutionFails(@TempDir Path dir) throws IOException { + var p1 = Files.createFile(dir.resolve("foo (1).c9r")); + var p2 = Files.createFile(dir.resolve("foo.c9r")); + Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn("baz"); + Node unresolved = new Node(dir.resolve("foo (1).c9r")); + unresolved.cleartextName = "bar.txt"; + unresolved.extractedCiphertext = "foo"; + + var conflictResolverSpy = Mockito.spy(conflictResolver); + Mockito.doThrow(IOException.class).when(conflictResolverSpy).resolveConflict(Mockito.any(), Mockito.any()); + + Stream result = Assertions.assertDoesNotThrow(() -> conflictResolverSpy.process(unresolved)); + Assertions.assertEquals(0, result.toList().size()); + Assertions.assertTrue(Files.exists(p1)); + Assertions.assertTrue(Files.exists(p2)); + var isConflictResolutionFailedEvent = (ArgumentMatcher) ev -> ev instanceof ConflictResolutionFailedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isConflictResolutionFailedEvent)); + } + } \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java b/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java index dac1d3039..42758aa6e 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java @@ -1,6 +1,9 @@ package org.cryptomator.cryptofs.fh; import org.cryptomator.cryptofs.CryptoFileSystemStats; +import org.cryptomator.cryptofs.CryptoPath; +import org.cryptomator.cryptofs.event.DecryptionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptofs.matchers.ByteBufferMatcher; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; @@ -11,11 +14,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import org.mockito.stubbing.Answer; import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.function.Supplier; import static org.cryptomator.cryptofs.matchers.ByteBufferMatcher.contains; @@ -25,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -44,7 +53,9 @@ public class ChunkLoaderTest { private final FileHeader header = mock(FileHeader.class); private final FileHeaderHolder headerHolder = mock(FileHeaderHolder.class); private final BufferPool bufferPool = mock(BufferPool.class); - private final ChunkLoader inTest = new ChunkLoader(cryptor, chunkIO, headerHolder, stats, bufferPool); + private final Consumer eventConsumer = mock(Consumer.class); + private final AtomicReference filePaths = new AtomicReference<>(new ClearAndCipherPath(mock(CryptoPath.class, "cleartext path"), mock(Path.class, "ciphertext filepath"))); + private final ChunkLoader inTest = new ChunkLoader(eventConsumer, filePaths, cryptor, chunkIO, headerHolder, stats, bufferPool); @BeforeEach public void setup() throws IOException { @@ -122,6 +133,23 @@ public void testLoadReturnsDecrytedDataNearEOF() throws IOException, Authenticat Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, data.capacity()); } + @Test + @DisplayName("load() rethrows on failed decryption the exception and creates an event") + public void testLoadThrowsAndNotifiesOnFailedDecryption() throws IOException { + long chunkIndex = 482L; + long chunkOffset = chunkIndex * CIPHERTEXT_CHUNK_SIZE + HEADER_SIZE; + when(chunkIO.read(argThat(hasAtLeastRemaining(CIPHERTEXT_CHUNK_SIZE)), eq(chunkOffset))).then(fillBufferWith((byte) 3, CIPHERTEXT_CHUNK_SIZE)); + doThrow(new AuthenticationFailedException("FAIL")) // + .when(fileContentCryptor).decryptChunk( // + argThat(contains(repeat(3).times(CIPHERTEXT_CHUNK_SIZE).asByteBuffer())), // + Mockito.any(), eq(chunkIndex), eq(header), eq(true) // + ); + + Assertions.assertThrows(AuthenticationFailedException.class, () -> inTest.load(chunkIndex)); + var isDecryptionFailedEvent = (ArgumentMatcher) ev -> ev instanceof DecryptionFailedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isDecryptionFailedEvent)); + } + private Answer fillBufferWith(byte value, int amount) { return invocation -> { ByteBuffer buffer = invocation.getArgument(0); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/FileHeaderHolderTest.java b/src/test/java/org/cryptomator/cryptofs/fh/FileHeaderHolderTest.java index ed93fac42..3accea3a7 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/FileHeaderHolderTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/FileHeaderHolderTest.java @@ -1,5 +1,8 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.CryptoPath; +import org.cryptomator.cryptofs.event.DecryptionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileHeader; @@ -10,6 +13,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import java.io.IOException; @@ -18,6 +23,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -34,15 +40,18 @@ public class FileHeaderHolderTest { private final FileHeaderCryptor fileHeaderCryptor = mock(FileHeaderCryptor.class); private final Cryptor cryptor = mock(Cryptor.class); - private final Path path = mock(Path.class, "openFile.txt"); - private final AtomicReference pathRef = new AtomicReference<>(path); + private final Path cipherPath = mock(Path.class, "cipherFile.c9r"); + private final CryptoPath clearPath = mock(CryptoPath.class, "openFile.txt"); + private final AtomicReference pathRef = new AtomicReference<>(new ClearAndCipherPath(clearPath, cipherPath)); + private final Consumer eventConsumer = mock(Consumer.class); - private final FileHeaderHolder inTest = new FileHeaderHolder(cryptor, pathRef); + private FileHeaderHolder inTest; @BeforeEach public void setup() throws IOException { when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); when(fileHeaderCryptor.encryptHeader(Mockito.any())).thenReturn(ByteBuffer.wrap(new byte[0])); + inTest = new FileHeaderHolder(eventConsumer, cryptor, pathRef); } @Nested @@ -66,7 +75,7 @@ public void setup() throws IOException, AuthenticationFailedException { } @Test - @DisplayName("load") + @DisplayName("load success") public void testLoadExisting() throws IOException, AuthenticationFailedException { FileHeader loadedHeader1 = inTest.loadExisting(channel); FileHeader loadedHeader2 = inTest.get(); @@ -81,6 +90,16 @@ public void testLoadExisting() throws IOException, AuthenticationFailedException Assertions.assertTrue(inTest.headerIsPersisted().get()); } + @Test + @DisplayName("load failure") + public void testLoadExistingFailure() { + Mockito.doThrow(AuthenticationFailedException.class).when(fileHeaderCryptor).decryptHeader(Mockito.any()); + + Assertions.assertThrows(IOException.class, () -> inTest.loadExisting(channel)); + var isDecryptionFailedEvent = (ArgumentMatcher) ev -> ev instanceof DecryptionFailedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isDecryptionFailedEvent)); + } + } @Nested diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index e50b7d574..12ee47093 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -2,6 +2,7 @@ import com.google.common.jimfs.Configuration; import com.google.common.jimfs.Jimfs; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.ReadonlyFlag; import org.cryptomator.cryptofs.ch.ChannelComponent; @@ -13,6 +14,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Nested; @@ -44,7 +46,9 @@ public class OpenCryptoFileTest { private static FileSystem FS; - private static AtomicReference CURRENT_FILE_PATH; + private static Path CURRENT_FILE_PATH; + private CryptoPath clearPath = mock(CryptoPath.class, "cleartext.txt"); + private AtomicReference paths; private ReadonlyFlag readonlyFlag = mock(ReadonlyFlag.class); private FileCloseListener closeListener = mock(FileCloseListener.class); private Cryptor cryptor = mock(Cryptor.class); @@ -60,7 +64,12 @@ public class OpenCryptoFileTest { @BeforeAll public static void setup() { FS = Jimfs.newFileSystem("OpenCryptoFileTest", Configuration.unix().toBuilder().setAttributeViews("basic", "posix").build()); - CURRENT_FILE_PATH = new AtomicReference<>(FS.getPath("currentFile")); + CURRENT_FILE_PATH = FS.getPath("currentCipherFile.c9r"); + } + + @BeforeEach + public void beforeEach() { + this.paths = new AtomicReference<>(new ClearAndCipherPath(clearPath, CURRENT_FILE_PATH)); } @AfterAll @@ -70,9 +79,9 @@ public static void tearDown() throws IOException { @Test public void testCloseTriggersCloseListener() { - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, paths, fileSize, lastModified, openCryptoFileComponent); openCryptoFile.close(); - verify(closeListener).close(CURRENT_FILE_PATH.get(), openCryptoFile); + verify(closeListener).close(paths.get().ciphertextPath(), openCryptoFile); } // tests https://github.com/cryptomator/cryptofs/issues/51 @@ -81,13 +90,13 @@ public void testCloseImmediatelyIfOpeningFirstChannelFails() { UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, paths, fileSize, lastModified, openCryptoFileComponent); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { openCryptoFile.newFileChannel(options); }); Assertions.assertSame(expectedException, exception); - verify(closeListener).close(CURRENT_FILE_PATH.get(), openCryptoFile); + verify(closeListener).close(paths.get().ciphertextPath(), openCryptoFile); } @Test @@ -101,7 +110,7 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, paths, fileSize, lastModified, openCryptoFileComponent); openCryptoFile.newFileChannel(options); verify(cleartextChannel).truncate(0L); @@ -113,7 +122,7 @@ public class InitFilHeaderTests { EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); FileChannel cipherFileChannel = Mockito.mock(FileChannel.class, "cipherFilechannel"); - OpenCryptoFile inTest = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent); + OpenCryptoFile inTest = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, paths, fileSize, lastModified, openCryptoFileComponent); @Test @DisplayName("Skip file header init, if the file header already exists in memory") @@ -196,8 +205,9 @@ public class FileChannelFactoryTest { @BeforeAll public void setup() throws IOException { FS = Jimfs.newFileSystem("OpenCryptoFileTest.FileChannelFactoryTest", Configuration.unix().toBuilder().setAttributeViews("basic", "posix").build()); - CURRENT_FILE_PATH = new AtomicReference<>(FS.getPath("currentFile")); - openCryptoFile = new OpenCryptoFile(closeListener,cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, realFileSize, lastModified, openCryptoFileComponent); + CURRENT_FILE_PATH = FS.getPath("currentCipherFile.c9r"); + paths = new AtomicReference<>(new ClearAndCipherPath(clearPath, CURRENT_FILE_PATH)); + openCryptoFile = new OpenCryptoFile(closeListener,cryptor, headerHolder, chunkIO, paths, realFileSize, lastModified, openCryptoFileComponent); cleartextFileChannel = mock(CleartextFileChannel.class); listener = new AtomicReference<>(); ciphertextChannel = new AtomicReference<>(); @@ -249,7 +259,7 @@ public void errorDuringCreationOfSecondChannel() { openCryptoFile.newFileChannel(options); }); Assertions.assertSame(expectedException, exception); - verify(closeListener, Mockito.never()).close(CURRENT_FILE_PATH.get(), openCryptoFile); + verify(closeListener, Mockito.never()).close(paths.get().ciphertextPath(), openCryptoFile); } @Test diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java index fa01dbfb1..373337d9a 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java @@ -1,28 +1,29 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mockito; -import javax.inject.Provider; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Path; -import java.nio.file.Paths; import static org.mockito.Mockito.mock; public class OpenCryptoFilesTest { - private final OpenCryptoFileComponent.Factory openCryptoFileComponentFactory = mock(OpenCryptoFileComponent.Factory.class); private final OpenCryptoFile file = mock(OpenCryptoFile.class, "file"); private final FileChannel ciphertextFileChannel = Mockito.mock(FileChannel.class); + private final CryptoPath cleartextPath = Mockito.mock(CryptoPath.class, "clear/text.txt"); + @TempDir + Path tmpDir; private OpenCryptoFiles inTest; @@ -49,22 +50,22 @@ public void testGetOrCreate() { Mockito.when(openCryptoFileComponentFactory.create(Mockito.any(), Mockito.any())).thenReturn(subComponent1, subComponent2); - Path p1 = Paths.get("/foo"); - Path p2 = Paths.get("/bar"); + Path p1 = tmpDir.resolve("/foo"); + Path p2 = tmpDir.resolve("/bar"); - Assertions.assertSame(file1, inTest.getOrCreate(p1)); - Assertions.assertSame(file1, inTest.getOrCreate(p1)); - Assertions.assertSame(file2, inTest.getOrCreate(p2)); + Assertions.assertSame(file1, inTest.getOrCreate(cleartextPath, p1)); + Assertions.assertSame(file1, inTest.getOrCreate(cleartextPath, p1)); + Assertions.assertSame(file2, inTest.getOrCreate(cleartextPath, p2)); Assertions.assertNotSame(file1, file2); } @Test public void testWriteCiphertextFile() throws IOException { - Path path = Paths.get("/foo"); + Path path = tmpDir.resolve("/foo"); EffectiveOpenOptions openOptions = Mockito.mock(EffectiveOpenOptions.class); ByteBuffer contents = StandardCharsets.UTF_8.encode("hello world"); - inTest.writeCiphertextFile(path, openOptions, contents); + inTest.writeCiphertextFile(cleartextPath, path, openOptions, contents); Mockito.verify(ciphertextFileChannel).write(contents); } @@ -72,7 +73,7 @@ public void testWriteCiphertextFile() throws IOException { @Test public void testReadCiphertextFile() throws IOException { byte[] contents = "hello world".getBytes(StandardCharsets.UTF_8); - Path path = Paths.get("/foo"); + Path path = tmpDir.resolve("/foo"); EffectiveOpenOptions openOptions = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(ciphertextFileChannel.size()).thenReturn((long) contents.length); Mockito.when(ciphertextFileChannel.read(Mockito.any(ByteBuffer.class))).thenAnswer(invocation -> { @@ -81,31 +82,33 @@ public void testReadCiphertextFile() throws IOException { return contents.length; }); - ByteBuffer bytesRead = inTest.readCiphertextFile(path, openOptions, 1337); + ByteBuffer bytesRead = inTest.readCiphertextFile(cleartextPath, path, openOptions, 1337); Assertions.assertEquals("hello world", StandardCharsets.UTF_8.decode(bytesRead).toString()); } @Test public void testTwoPhaseMoveFailsWhenTargetIsOpened() throws IOException { - Path src = Paths.get("/src").toAbsolutePath(); - Path dst = Paths.get("/dst").toAbsolutePath(); - inTest.getOrCreate(dst); + Path src = tmpDir.resolve("/src").toAbsolutePath(); + Path dst = tmpDir.resolve("/dst").toAbsolutePath(); + CryptoPath cleartextDst = Mockito.mock(CryptoPath.class, "/target/file.txt"); + inTest.getOrCreate(cleartextPath, dst); Assertions.assertThrows(FileAlreadyExistsException.class, () -> { - inTest.prepareMove(src, dst); + inTest.prepareMove(src, cleartextDst, dst); }); } @Test public void testTwoPhaseMoveDoesntChangeAnythingWhenRolledBack() throws IOException { - Path src = Paths.get("/src"); - Path dst = Paths.get("/dst"); - inTest.getOrCreate(src); + Path src = tmpDir.resolve("/src"); + Path dst = tmpDir.resolve("/dst"); + CryptoPath cleartextDst = Mockito.mock(CryptoPath.class, "/target/file.txt"); + inTest.getOrCreate(cleartextPath, src); Assertions.assertTrue(inTest.get(src).isPresent()); Assertions.assertFalse(inTest.get(dst).isPresent()); - try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, dst)) { + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, cleartextDst, dst)) { twoPhaseMove.rollback(); } Assertions.assertTrue(inTest.get(src).isPresent()); @@ -114,44 +117,48 @@ public void testTwoPhaseMoveDoesntChangeAnythingWhenRolledBack() throws IOExcept @Test public void testTwoPhaseMoveChangesReferencesWhenCommitted() throws IOException { - Path src = Paths.get("/src").toAbsolutePath(); - Path dst = Paths.get("/dst").toAbsolutePath(); - inTest.getOrCreate(src); + Path src = tmpDir.resolve("/src").toAbsolutePath(); + Path dst = tmpDir.resolve("/dst").toAbsolutePath(); + CryptoPath cleartextDst = Mockito.mock(CryptoPath.class, "/target/file.txt"); + inTest.getOrCreate(cleartextPath, src); Assertions.assertTrue(inTest.get(src).isPresent()); Assertions.assertFalse(inTest.get(dst).isPresent()); OpenCryptoFile srcFile = inTest.get(src).get(); - try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, dst)) { + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, cleartextDst, dst)) { twoPhaseMove.commit(); } Assertions.assertFalse(inTest.get(src).isPresent()); Assertions.assertTrue(inTest.get(dst).isPresent()); OpenCryptoFile dstFile = inTest.get(dst).get(); Assertions.assertSame(srcFile, dstFile); + Mockito.verify(dstFile).updateCurrentFilePath(Mockito.argThat(ps -> ps.ciphertextPath().equals(dst) && ps.cleartextPath().equals(cleartextDst))); } @Test public void testCloseClosesRemainingOpenFiles() { Path path1 = Mockito.mock(Path.class, "/file1"); + ClearAndCipherPath paths1 = new ClearAndCipherPath(cleartextPath, path1); Mockito.when(path1.toAbsolutePath()).thenReturn(path1); Mockito.when(path1.normalize()).thenReturn(path1); OpenCryptoFileComponent subComponent1 = mock(OpenCryptoFileComponent.class); OpenCryptoFile file1 = mock(OpenCryptoFile.class, "file1"); - Mockito.when(openCryptoFileComponentFactory.create(Mockito.eq(path1), Mockito.any())).thenReturn(subComponent1); + Mockito.doReturn(subComponent1).when(openCryptoFileComponentFactory).create(Mockito.argThat(ps -> ps.ciphertextPath().equals(path1)), Mockito.any()); Mockito.when(subComponent1.openCryptoFile()).thenReturn(file1); - Mockito.when(file1.getCurrentFilePath()).thenReturn(path1); + Mockito.when(file1.getCurrentFilePaths()).thenReturn(paths1); Path path2 = Mockito.mock(Path.class, "/file2"); + ClearAndCipherPath paths2 = new ClearAndCipherPath(cleartextPath, path2); Mockito.when(path2.toAbsolutePath()).thenReturn(path2); Mockito.when(path2.normalize()).thenReturn(path2); OpenCryptoFileComponent subComponent2 = mock(OpenCryptoFileComponent.class); OpenCryptoFile file2 = mock(OpenCryptoFile.class, "file2"); - Mockito.when(openCryptoFileComponentFactory.create(Mockito.eq(path2), Mockito.any())).thenReturn(subComponent2); + Mockito.doReturn(subComponent2).when(openCryptoFileComponentFactory).create(Mockito.argThat(ps -> ps.ciphertextPath().equals(path2)), Mockito.any()); Mockito.when(subComponent2.openCryptoFile()).thenReturn(file2); - Mockito.when(file2.getCurrentFilePath()).thenReturn(path2); + Mockito.when(file2.getCurrentFilePaths()).thenReturn(paths2); - Assertions.assertEquals(file1, inTest.getOrCreate(path1)); - Assertions.assertEquals(file2, inTest.getOrCreate(path2)); + Assertions.assertEquals(file1, inTest.getOrCreate(cleartextPath, path1)); + Assertions.assertEquals(file2, inTest.getOrCreate(cleartextPath, path2)); Assertions.assertEquals(file1, inTest.get(path1).get()); Assertions.assertEquals(file2, inTest.get(path2).get()); inTest.close();