Skip to content

Commit 9082f02

Browse files
Feature: Notification API for Filesystem events (#277)
Closes #89 Co-authored-by: Sebastian Stenzel <[email protected]>
1 parent 72764a0 commit 9082f02

15 files changed

+318
-16
lines changed

src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
requires java.compiler;
2323

2424
exports org.cryptomator.cryptofs;
25+
exports org.cryptomator.cryptofs.event;
2526
exports org.cryptomator.cryptofs.common;
2627
exports org.cryptomator.cryptofs.health.api;
2728
exports org.cryptomator.cryptofs.migration;

src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.cryptomator.cryptofs.attr.AttributeComponent;
1111
import org.cryptomator.cryptofs.attr.AttributeViewComponent;
1212
import org.cryptomator.cryptofs.dir.DirectoryStreamComponent;
13+
import org.cryptomator.cryptofs.event.FilesystemEvent;
1314
import org.cryptomator.cryptofs.fh.OpenCryptoFileComponent;
1415
import org.slf4j.Logger;
1516
import org.slf4j.LoggerFactory;
@@ -19,6 +20,7 @@
1920
import java.nio.file.Files;
2021
import java.nio.file.Path;
2122
import java.util.Optional;
23+
import java.util.function.Consumer;
2224

2325
@Module(subcomponents = {AttributeComponent.class, AttributeViewComponent.class, OpenCryptoFileComponent.class, DirectoryStreamComponent.class})
2426
class CryptoFileSystemModule {
@@ -35,4 +37,17 @@ public Optional<FileStore> provideNativeFileStore(@PathToVault Path pathToVault)
3537
return Optional.empty();
3638
}
3739
}
40+
41+
@Provides
42+
@CryptoFileSystemScoped
43+
public Consumer<FilesystemEvent> provideFilesystemEventConsumer(CryptoFileSystemProperties fsProps) {
44+
var eventConsumer = fsProps.filesystemEventConsumer();
45+
return event -> {
46+
try {
47+
eventConsumer.accept(event);
48+
} catch (RuntimeException e) {
49+
LOG.warn("Filesystem event consumer failed with exception when processing event {}", event, e);
50+
}
51+
};
52+
}
3853
}

src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
package org.cryptomator.cryptofs;
1010

1111
import com.google.common.base.Strings;
12+
import org.cryptomator.cryptofs.event.FilesystemEvent;
1213
import org.cryptomator.cryptolib.api.CryptorProvider;
1314
import org.cryptomator.cryptolib.api.MasterkeyLoader;
1415

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

8182
static final String DEFAULT_MASTERKEY_FILENAME = "masterkey.cryptomator";
8283

84+
/**
85+
* Key identifying the function to call for notifications.
86+
*
87+
* @since 2.9.0
88+
*/
89+
public static final String PROPERTY_EVENT_CONSUMER = "fsEventConsumer";
90+
91+
static final Consumer<FilesystemEvent> DEFAULT_EVENT_CONSUMER = ignored -> {};
92+
8393
/**
8494
* Key identifying the filesystem flags.
8595
*
@@ -113,6 +123,7 @@ private CryptoFileSystemProperties(Builder builder) {
113123
Map.entry(PROPERTY_FILESYSTEM_FLAGS, builder.flags), //
114124
Map.entry(PROPERTY_VAULTCONFIG_FILENAME, builder.vaultConfigFilename), //
115125
Map.entry(PROPERTY_MASTERKEY_FILENAME, builder.masterkeyFilename), //
126+
Map.entry(PROPERTY_EVENT_CONSUMER, builder.eventConsumer), //
116127
Map.entry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, builder.maxCleartextNameLength), //
117128
Map.entry(PROPERTY_SHORTENING_THRESHOLD, builder.shorteningThreshold), //
118129
Map.entry(PROPERTY_CIPHER_COMBO, builder.cipherCombo) //
@@ -153,6 +164,11 @@ int shorteningThreshold() {
153164
return (int) get(PROPERTY_SHORTENING_THRESHOLD);
154165
}
155166

167+
@SuppressWarnings("unchecked")
168+
Consumer<FilesystemEvent> filesystemEventConsumer() {
169+
return (Consumer<FilesystemEvent>) get(PROPERTY_EVENT_CONSUMER);
170+
}
171+
156172
@Override
157173
public Set<Entry<String, Object>> entrySet() {
158174
return entries;
@@ -208,6 +224,7 @@ public static class Builder {
208224
private String masterkeyFilename = DEFAULT_MASTERKEY_FILENAME;
209225
private int maxCleartextNameLength = DEFAULT_MAX_CLEARTEXT_NAME_LENGTH;
210226
private int shorteningThreshold = DEFAULT_SHORTENING_THRESHOLD;
227+
private Consumer<FilesystemEvent> eventConsumer = DEFAULT_EVENT_CONSUMER;
211228

212229
private Builder() {
213230
}
@@ -220,6 +237,7 @@ private Builder(Map<String, ?> properties) {
220237
checkedSet(Integer.class, PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, properties, this::withMaxCleartextNameLength);
221238
checkedSet(Integer.class, PROPERTY_SHORTENING_THRESHOLD, properties, this::withShorteningThreshold);
222239
checkedSet(CryptorProvider.Scheme.class, PROPERTY_CIPHER_COMBO, properties, this::withCipherCombo);
240+
checkedSet(Consumer.class, PROPERTY_EVENT_CONSUMER, properties, this::withFilesystemEventConsumer);
223241
}
224242

225243
private <T> void checkedSet(Class<T> type, String key, Map<String, ?> properties, Consumer<T> setter) {
@@ -334,6 +352,21 @@ public Builder withMasterkeyFilename(String masterkeyFilename) {
334352
return this;
335353
}
336354

355+
/**
356+
* Sets the consumer for filesystem events
357+
*
358+
* @param eventConsumer the consumer to receive filesystem events
359+
* @return this
360+
* @since 2.8.0
361+
*/
362+
public Builder withFilesystemEventConsumer(Consumer<FilesystemEvent> eventConsumer) {
363+
if (eventConsumer == null) {
364+
throw new IllegalArgumentException("Parameter eventConsumer must not be null");
365+
}
366+
this.eventConsumer = eventConsumer;
367+
return this;
368+
}
369+
337370
/**
338371
* Validates the values and creates new {@link CryptoFileSystemProperties}.
339372
*

src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import com.google.common.io.RecursiveDeleteOption;
77
import org.cryptomator.cryptofs.VaultConfig;
88
import org.cryptomator.cryptofs.common.Constants;
9+
import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent;
10+
import org.cryptomator.cryptofs.event.ConflictResolvedEvent;
11+
import org.cryptomator.cryptofs.event.FilesystemEvent;
912
import org.cryptomator.cryptolib.api.Cryptor;
1013
import org.slf4j.Logger;
1114
import org.slf4j.LoggerFactory;
@@ -18,6 +21,7 @@
1821
import java.nio.file.NoSuchFileException;
1922
import java.nio.file.Path;
2023
import java.nio.file.StandardCopyOption;
24+
import java.util.function.Consumer;
2125
import java.util.stream.Stream;
2226

2327
import static org.cryptomator.cryptofs.common.Constants.DIR_FILE_NAME;
@@ -33,14 +37,18 @@ class C9rConflictResolver {
3337
private final Cryptor cryptor;
3438
private final byte[] dirId;
3539
private final int maxC9rFileNameLength;
40+
private final Path cleartextPath;
3641
private final int maxCleartextFileNameLength;
42+
private final Consumer<FilesystemEvent> eventConsumer;
3743

3844
@Inject
39-
public C9rConflictResolver(Cryptor cryptor, @Named("dirId") String dirId, VaultConfig vaultConfig) {
45+
public C9rConflictResolver(Cryptor cryptor, @Named("dirId") String dirId, VaultConfig vaultConfig, Consumer<FilesystemEvent> eventConsumer, @Named("cleartextPath") Path cleartextPath) {
4046
this.cryptor = cryptor;
4147
this.dirId = dirId.getBytes(StandardCharsets.US_ASCII);
4248
this.maxC9rFileNameLength = vaultConfig.getShorteningThreshold();
49+
this.cleartextPath = cleartextPath;
4350
this.maxCleartextFileNameLength = (maxC9rFileNameLength - 4) / 4 * 3 - 16; // math from FileSystemCapabilityChecker.determineSupportedCleartextFileNameLength()
51+
this.eventConsumer = eventConsumer;
4452
}
4553

4654
public Stream<Node> process(Node node) {
@@ -61,13 +69,15 @@ public Stream<Node> process(Node node) {
6169
Path canonicalPath = node.ciphertextPath.resolveSibling(canonicalCiphertextFileName);
6270
return resolveConflict(node, canonicalPath);
6371
} catch (IOException e) {
72+
eventConsumer.accept(new ConflictResolutionFailedEvent(cleartextPath.resolve(node.cleartextName), node.ciphertextPath, e));
6473
LOG.error("Failed to resolve conflict for {}", node.ciphertextPath, e);
6574
return Stream.empty();
6675
}
6776
}
6877
}
6978

70-
private Stream<Node> resolveConflict(Node conflicting, Path canonicalPath) throws IOException {
79+
//visible for testing
80+
Stream<Node> resolveConflict(Node conflicting, Path canonicalPath) throws IOException {
7181
Path conflictingPath = conflicting.ciphertextPath;
7282
if (resolveConflictTrivially(canonicalPath, conflictingPath)) {
7383
Node resolved = new Node(canonicalPath);
@@ -132,6 +142,7 @@ private Stream<Node> renameConflictingFile(Path canonicalPath, Node conflicting)
132142
Node node = new Node(alternativePath);
133143
node.cleartextName = alternativeCleartext;
134144
node.extractedCiphertext = alternativeCiphertext;
145+
eventConsumer.accept(new ConflictResolvedEvent(cleartextPath.resolve(cleartext), conflicting.ciphertextPath, cleartextPath.resolve(alternativeCleartext), alternativePath));
135146
return Stream.of(node);
136147
}
137148

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.cryptomator.cryptofs.event;
2+
3+
import java.nio.file.Path;
4+
5+
/**
6+
* Emitted, if the conflict resolution inside an encrypted directory failed
7+
*
8+
* @param canonicalCleartextPath path of the canonical file within the cryptographic filesystem
9+
* @param conflictingCiphertextPath path of the encrypted, conflicting file
10+
* @param reason exception, why the resolution failed
11+
*/
12+
public record ConflictResolutionFailedEvent(Path canonicalCleartextPath, Path conflictingCiphertextPath, Exception reason) implements FilesystemEvent {
13+
14+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package org.cryptomator.cryptofs.event;
2+
3+
import java.nio.file.Path;
4+
5+
/**
6+
* Emitted, if a conflict inside an encrypted directory was resolved.
7+
* <p>
8+
* 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.
9+
* The file <i>without</i> the suffix is called <b>canonical</b>.
10+
* The file <i>with the suffix</i> is called <b>conflicting</b>
11+
* On successful conflict resolution the conflicting file is renamed to the <b>resolved</b> file
12+
*
13+
* @param canonicalCleartextPath path of the canonical file within the cryptographic filesystem
14+
* @param conflictingCiphertextPath path of the encrypted, conflicting file
15+
* @param resolvedCleartextPath path of the resolved file within the cryptographic filesystem
16+
* @param resolvedCiphertextPath path of the resolved, encrypted file
17+
*/
18+
public record ConflictResolvedEvent(Path canonicalCleartextPath, Path conflictingCiphertextPath, Path resolvedCleartextPath, Path resolvedCiphertextPath) implements FilesystemEvent {
19+
20+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.cryptomator.cryptofs.event;
2+
3+
import org.cryptomator.cryptolib.api.AuthenticationFailedException;
4+
5+
import java.nio.file.Path;
6+
7+
/**
8+
* Emitted, if a decryption operation fails.
9+
*
10+
* @param ciphertextPath path to the encrypted resource
11+
* @param e thrown exception
12+
*/
13+
public record DecryptionFailedEvent(Path ciphertextPath, AuthenticationFailedException e) implements FilesystemEvent {
14+
15+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package org.cryptomator.cryptofs.event;
2+
3+
import java.util.function.Consumer;
4+
5+
/**
6+
* Common interface for all filesystem events.
7+
* <p>
8+
* Events are emitted via the notification method set in the properties during filesystem creation, see {@link org.cryptomator.cryptofs.CryptoFileSystemProperties.Builder#withFilesystemEventConsumer(Consumer)}.
9+
* <p>
10+
* To get a specific event type, use the enhanced switch pattern or typecasting in if-instance of, e.g.
11+
* {@code
12+
* FilesystemEvent fse;
13+
* switch (fse) {
14+
* case DecryptionFailedEvent e -> //do stuff
15+
* case ConflictResolvedEvent e -> //do other stuff
16+
* //other cases
17+
* }
18+
* if( fse instanceof DecryptionFailedEvent dfe) {
19+
* //do more stuff
20+
* }
21+
* }.
22+
*
23+
* @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.
24+
*/
25+
public sealed interface FilesystemEvent permits ConflictResolutionFailedEvent, ConflictResolvedEvent, DecryptionFailedEvent {
26+
27+
}

src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
11
package org.cryptomator.cryptofs.fh;
22

33
import org.cryptomator.cryptofs.CryptoFileSystemStats;
4+
import org.cryptomator.cryptofs.event.DecryptionFailedEvent;
5+
import org.cryptomator.cryptofs.event.FilesystemEvent;
46
import org.cryptomator.cryptolib.api.AuthenticationFailedException;
57
import org.cryptomator.cryptolib.api.Cryptor;
68

79
import javax.inject.Inject;
10+
import javax.inject.Named;
811
import java.io.IOException;
912
import java.nio.ByteBuffer;
13+
import java.nio.file.Path;
14+
import java.util.concurrent.atomic.AtomicReference;
15+
import java.util.function.Consumer;
1016

1117
@OpenFileScoped
1218
class ChunkLoader {
1319

20+
private final Consumer<FilesystemEvent> eventConsumer;
21+
private final AtomicReference<Path> path;
1422
private final Cryptor cryptor;
1523
private final ChunkIO ciphertext;
1624
private final FileHeaderHolder headerHolder;
1725
private final CryptoFileSystemStats stats;
1826
private final BufferPool bufferPool;
1927

2028
@Inject
21-
public ChunkLoader(Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) {
29+
public ChunkLoader(Consumer<FilesystemEvent> eventConsumer, @CurrentOpenFilePath AtomicReference<Path> path, Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) {
30+
this.eventConsumer = eventConsumer;
31+
this.path = path;
2232
this.cryptor = cryptor;
2333
this.ciphertext = ciphertext;
2434
this.headerHolder = headerHolder;
@@ -42,6 +52,9 @@ public ByteBuffer load(Long chunkIndex) throws IOException, AuthenticationFailed
4252
stats.addBytesDecrypted(cleartextBuf.remaining());
4353
}
4454
return cleartextBuf;
55+
} catch (AuthenticationFailedException e) {
56+
eventConsumer.accept(new DecryptionFailedEvent(path.get(), e));
57+
throw e;
4558
} finally {
4659
bufferPool.recycle(ciphertextBuf);
4760
}

src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package org.cryptomator.cryptofs.fh;
22

3+
import org.cryptomator.cryptofs.event.DecryptionFailedEvent;
4+
import org.cryptomator.cryptofs.event.FilesystemEvent;
5+
import org.cryptomator.cryptolib.api.AuthenticationFailedException;
36
import org.cryptomator.cryptolib.api.CryptoException;
47
import org.cryptomator.cryptolib.api.Cryptor;
58
import org.cryptomator.cryptolib.api.FileHeader;
@@ -13,20 +16,23 @@
1316
import java.nio.file.Path;
1417
import java.util.concurrent.atomic.AtomicBoolean;
1518
import java.util.concurrent.atomic.AtomicReference;
19+
import java.util.function.Consumer;
1620

1721
@OpenFileScoped
1822
public class FileHeaderHolder {
1923

2024
private static final Logger LOG = LoggerFactory.getLogger(FileHeaderHolder.class);
2125

26+
private final Consumer<FilesystemEvent> eventConsumer;
2227
private final Cryptor cryptor;
2328
private final AtomicReference<Path> path;
2429
private final AtomicReference<FileHeader> header = new AtomicReference<>();
2530
private final AtomicReference<ByteBuffer> encryptedHeader = new AtomicReference<>();
2631
private final AtomicBoolean isPersisted = new AtomicBoolean();
2732

2833
@Inject
29-
public FileHeaderHolder(Cryptor cryptor, @CurrentOpenFilePath AtomicReference<Path> path) {
34+
public FileHeaderHolder(Consumer<FilesystemEvent> eventConsumer, Cryptor cryptor, @CurrentOpenFilePath AtomicReference<Path> path) {
35+
this.eventConsumer = eventConsumer;
3036
this.cryptor = cryptor;
3137
this.path = path;
3238
}
@@ -75,6 +81,9 @@ FileHeader loadExisting(FileChannel ch) throws IOException {
7581
isPersisted.set(true);
7682
return existingHeader;
7783
} catch (IllegalArgumentException | CryptoException e) {
84+
if (e instanceof AuthenticationFailedException afe) {
85+
eventConsumer.accept(new DecryptionFailedEvent(path.get(), afe));
86+
}
7887
throw new IOException("Unable to decrypt header of file " + path.get(), e);
7988
}
8089
}

0 commit comments

Comments
 (0)