Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -35,4 +37,17 @@ public Optional<FileStore> provideNativeFileStore(@PathToVault Path pathToVault)
return Optional.empty();
}
}

@Provides
@CryptoFileSystemScoped
public Consumer<FilesystemEvent> provideFilesystemEventConsumer(CryptoFileSystemProperties fsProps) {
var eventConsumer = fsProps.filesystemEventConsumer();
return event -> {
try {
eventConsumer.accept(event);
} catch (RuntimeException e) {
LOG.warn("Filesystem event consumer failed with exception when processing event {}", event, e);
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -80,6 +81,15 @@ public class CryptoFileSystemProperties extends AbstractMap<String, Object> {

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_EVENT_CONSUMER = "fsEventConsumer";

static final Consumer<FilesystemEvent> DEFAULT_EVENT_CONSUMER = ignored -> {};

/**
* Key identifying the filesystem flags.
*
Expand Down Expand Up @@ -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_EVENT_CONSUMER, 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) //
Expand Down Expand Up @@ -153,6 +164,11 @@ int shorteningThreshold() {
return (int) get(PROPERTY_SHORTENING_THRESHOLD);
}

@SuppressWarnings("unchecked")
Consumer<FilesystemEvent> filesystemEventConsumer() {
return (Consumer<FilesystemEvent>) get(PROPERTY_EVENT_CONSUMER);
}

@Override
public Set<Entry<String, Object>> entrySet() {
return entries;
Expand Down Expand Up @@ -208,6 +224,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<FilesystemEvent> eventConsumer = DEFAULT_EVENT_CONSUMER;

private Builder() {
}
Expand All @@ -220,6 +237,7 @@ private Builder(Map<String, ?> 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_EVENT_CONSUMER, properties, this::withFilesystemEventConsumer);
}

private <T> void checkedSet(Class<T> type, String key, Map<String, ?> properties, Consumer<T> setter) {
Expand Down Expand Up @@ -334,6 +352,21 @@ 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<FilesystemEvent> eventConsumer) {
if (eventConsumer == null) {
throw new IllegalArgumentException("Parameter eventConsumer must not be null");
}
this.eventConsumer = eventConsumer;
return this;
}

/**
* Validates the values and creates new {@link CryptoFileSystemProperties}.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<FilesystemEvent> eventConsumer;

@Inject
public C9rConflictResolver(Cryptor cryptor, @Named("dirId") String dirId, VaultConfig vaultConfig) {
public C9rConflictResolver(Cryptor cryptor, @Named("dirId") String dirId, VaultConfig vaultConfig, Consumer<FilesystemEvent> 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<Node> process(Node node) {
Expand All @@ -61,13 +69,15 @@ public Stream<Node> process(Node node) {
Path canonicalPath = node.ciphertextPath.resolveSibling(canonicalCiphertextFileName);
return resolveConflict(node, canonicalPath);
} catch (IOException e) {
eventConsumer.accept(new ConflictResolutionFailedEvent(cleartextPath.resolve(node.cleartextName), node.ciphertextPath, e));
LOG.error("Failed to resolve conflict for {}", node.ciphertextPath, e);
return Stream.empty();
}
}
}

private Stream<Node> resolveConflict(Node conflicting, Path canonicalPath) throws IOException {
//visible for testing
Stream<Node> resolveConflict(Node conflicting, Path canonicalPath) throws IOException {
Path conflictingPath = conflicting.ciphertextPath;
if (resolveConflictTrivially(canonicalPath, conflictingPath)) {
Node resolved = new Node(canonicalPath);
Expand Down Expand Up @@ -132,6 +142,7 @@ private Stream<Node> renameConflictingFile(Path canonicalPath, Node conflicting)
Node node = new Node(alternativePath);
node.cleartextName = alternativeCleartext;
node.extractedCiphertext = alternativeCiphertext;
eventConsumer.accept(new ConflictResolvedEvent(cleartextPath.resolve(cleartext), conflicting.ciphertextPath, cleartextPath.resolve(alternativeCleartext), alternativePath));
return Stream.of(node);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 canonicalCleartextPath path of the canonical file within the cryptographic filesystem
* @param conflictingCiphertextPath path of the encrypted, conflicting file
* @param reason exception, why the resolution failed
*/
public record ConflictResolutionFailedEvent(Path canonicalCleartextPath, Path conflictingCiphertextPath, Exception reason) implements FilesystemEvent {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.cryptomator.cryptofs.event;

import java.nio.file.Path;

/**
* Emitted, if a conflict inside an encrypted directory was resolved.
* <p>
* A conflict exists, if two encrypted files with the same base64url string exist, but the second file has an arbitrary suffix before the file extension.
* The file <i>without</i> the suffix is called <b>canonical</b>.
* The file <i>with the suffix</i> is called <b>conflicting</b>
* On successful conflict resolution the conflicting file is renamed to the <b>resolved</b> file
*
* @param canonicalCleartextPath path of the canonical file within the cryptographic filesystem
* @param conflictingCiphertextPath path of the encrypted, conflicting file
* @param resolvedCleartextPath path of the resolved file within the cryptographic filesystem
* @param resolvedCiphertextPath path of the resolved, encrypted file
*/
public record ConflictResolvedEvent(Path canonicalCleartextPath, Path conflictingCiphertextPath, Path resolvedCleartextPath, Path resolvedCiphertextPath) implements FilesystemEvent {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.cryptomator.cryptofs.event;

import org.cryptomator.cryptolib.api.AuthenticationFailedException;

import java.nio.file.Path;

/**
* Emitted, if a decryption operation fails.
*
* @param ciphertextPath path to the encrypted resource
* @param e thrown exception
*/
public record DecryptionFailedEvent(Path ciphertextPath, AuthenticationFailedException e) implements FilesystemEvent {

}
27 changes: 27 additions & 0 deletions src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.cryptomator.cryptofs.event;

import java.util.function.Consumer;

/**
* Common interface for all filesystem events.
* <p>
* Events are emitted via the notification method set in the properties during filesystem creation, see {@link org.cryptomator.cryptofs.CryptoFileSystemProperties.Builder#withFilesystemEventConsumer(Consumer)}.
* <p>
* 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 e -> //do stuff
* case ConflictResolvedEvent e -> //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 {

}
15 changes: 14 additions & 1 deletion src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
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 javax.inject.Named;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;

@OpenFileScoped
class ChunkLoader {

private final Consumer<FilesystemEvent> eventConsumer;
private final AtomicReference<Path> path;
private final Cryptor cryptor;
private final ChunkIO ciphertext;
private final FileHeaderHolder headerHolder;
private final CryptoFileSystemStats stats;
private final BufferPool bufferPool;

@Inject
public ChunkLoader(Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) {
public ChunkLoader(Consumer<FilesystemEvent> eventConsumer, @CurrentOpenFilePath AtomicReference<Path> path, Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) {
this.eventConsumer = eventConsumer;
this.path = path;
this.cryptor = cryptor;
this.ciphertext = ciphertext;
this.headerHolder = headerHolder;
Expand All @@ -42,6 +52,9 @@ public ByteBuffer load(Long chunkIndex) throws IOException, AuthenticationFailed
stats.addBytesDecrypted(cleartextBuf.remaining());
}
return cleartextBuf;
} catch (AuthenticationFailedException e) {
eventConsumer.accept(new DecryptionFailedEvent(path.get(), e));
throw e;
} finally {
bufferPool.recycle(ciphertextBuf);
}
Expand Down
11 changes: 10 additions & 1 deletion src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,20 +16,23 @@
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<FilesystemEvent> eventConsumer;
private final Cryptor cryptor;
private final AtomicReference<Path> path;
private final AtomicReference<FileHeader> header = new AtomicReference<>();
private final AtomicReference<ByteBuffer> encryptedHeader = new AtomicReference<>();
private final AtomicBoolean isPersisted = new AtomicBoolean();

@Inject
public FileHeaderHolder(Cryptor cryptor, @CurrentOpenFilePath AtomicReference<Path> path) {
public FileHeaderHolder(Consumer<FilesystemEvent> eventConsumer, Cryptor cryptor, @CurrentOpenFilePath AtomicReference<Path> path) {
this.eventConsumer = eventConsumer;
this.cryptor = cryptor;
this.path = path;
}
Expand Down Expand Up @@ -75,6 +81,9 @@ FileHeader loadExisting(FileChannel ch) throws IOException {
isPersisted.set(true);
return existingHeader;
} catch (IllegalArgumentException | CryptoException e) {
if (e instanceof AuthenticationFailedException afe) {
eventConsumer.accept(new DecryptionFailedEvent(path.get(), afe));
}
throw new IOException("Unable to decrypt header of file " + path.get(), e);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.cryptomator.cryptofs;

import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent;
import org.cryptomator.cryptofs.event.FilesystemEvent;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.nio.file.Path;
import java.util.function.Consumer;

import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class CryptoFileSystemModuleTest {

CryptoFileSystemModule inTest = new CryptoFileSystemModule();

@Test
void testEventConsumerIsDecorated() {
var p = Mockito.mock(Path.class);
var event = new ConflictResolutionFailedEvent(p, p, new RuntimeException());
var eventConsumer = (Consumer<FilesystemEvent>) mock(Consumer.class);
doThrow(new RuntimeException("fail")).when(eventConsumer).accept(event);
var props = mock(CryptoFileSystemProperties.class);
when(props.filesystemEventConsumer()).thenReturn(eventConsumer);

var decoratedConsumer = inTest.provideFilesystemEventConsumer(props);
Assertions.assertDoesNotThrow(() -> decoratedConsumer.accept(event));
verify(eventConsumer).accept(event);
}

}
Loading