diff --git a/pom.xml b/pom.xml index 9edc1126..35a0e9df 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.cryptomator cryptofs - 2.6.2 + 2.6.3 Cryptomator Crypto Filesystem This library provides the Java filesystem provider used by Cryptomator. https://github.com/cryptomator/cryptofs @@ -22,6 +22,7 @@ 4.3.0 2.44.2 31.1-jre + 3.1.4 2.0.3 @@ -81,6 +82,21 @@ guava ${guava.version} + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + org.checkerframework + checker-qual + + + com.google.errorprone + error_prone_annotations + + + org.slf4j slf4j-api diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 97e5da36..5cbfb094 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,5 +1,4 @@ import org.cryptomator.cryptofs.CryptoFileSystemProvider; -import org.cryptomator.cryptofs.common.CiphertextFileType; import org.cryptomator.cryptofs.health.api.HealthCheck; import org.cryptomator.cryptofs.health.dirid.DirIdCheck; import org.cryptomator.cryptofs.health.shortened.ShortenedNamesCheck; @@ -10,6 +9,7 @@ module org.cryptomator.cryptofs { requires transitive org.cryptomator.cryptolib; requires com.google.common; + requires com.github.benmanes.caffeine; requires org.slf4j; requires dagger; requires com.auth0.jwt; diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoPathMapper.java b/src/main/java/org/cryptomator/cryptofs/CryptoPathMapper.java index 136d697f..8e3b8120 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoPathMapper.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoPathMapper.java @@ -8,11 +8,9 @@ *******************************************************************************/ package org.cryptomator.cryptofs; -import com.google.common.base.Throwables; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; import com.google.common.io.BaseEncoding; import org.cryptomator.cryptofs.common.CiphertextFileType; import org.cryptomator.cryptofs.common.Constants; @@ -22,6 +20,7 @@ import javax.inject.Inject; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; @@ -32,7 +31,6 @@ import java.time.Duration; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.ExecutionException; import static org.cryptomator.cryptofs.common.Constants.DATA_DIR_NAME; @@ -61,8 +59,8 @@ public class CryptoPathMapper { this.dirIdProvider = dirIdProvider; this.longFileNameProvider = longFileNameProvider; this.vaultConfig = vaultConfig; - this.ciphertextNames = CacheBuilder.newBuilder().maximumSize(MAX_CACHED_CIPHERTEXT_NAMES).build(CacheLoader.from(this::getCiphertextFileName)); - this.ciphertextDirectories = CacheBuilder.newBuilder().maximumSize(MAX_CACHED_DIR_PATHS).expireAfterWrite(MAX_CACHE_AGE).build(); + this.ciphertextNames = Caffeine.newBuilder().maximumSize(MAX_CACHED_CIPHERTEXT_NAMES).build(this::getCiphertextFileName); + this.ciphertextDirectories = Caffeine.newBuilder().maximumSize(MAX_CACHED_DIR_PATHS).expireAfterWrite(MAX_CACHE_AGE).build(); this.rootDirectory = resolveDirectory(Constants.ROOT_DIR_ID); } @@ -127,7 +125,7 @@ public CiphertextFilePath getCiphertextFilePath(CryptoPath cleartextPath) throws } public CiphertextFilePath getCiphertextFilePath(Path parentCiphertextDir, String parentDirId, String cleartextName) { - String ciphertextName = ciphertextNames.getUnchecked(new DirIdAndName(parentDirId, cleartextName)); + String ciphertextName = ciphertextNames.get(new DirIdAndName(parentDirId, cleartextName)); Path c9rPath = parentCiphertextDir.resolve(ciphertextName); if (ciphertextName.length() > vaultConfig.getShorteningThreshold()) { LongFileNameProvider.DeflatedFileName deflatedFileName = longFileNameProvider.deflate(c9rPath); @@ -160,13 +158,16 @@ public CiphertextDirectory getCiphertextDir(CryptoPath cleartextPath) throws IOE return rootDirectory; } else { try { - return ciphertextDirectories.get(cleartextPath, () -> { - Path dirFile = getCiphertextFilePath(cleartextPath).getDirFilePath(); - return resolveDirectory(dirFile); + return ciphertextDirectories.get(cleartextPath, p -> { + try { + Path dirFile = getCiphertextFilePath(p).getDirFilePath(); + return resolveDirectory(dirFile); + } catch (IOException e) { + throw new UncheckedIOException(e); + } }); - } catch (ExecutionException e) { - Throwables.throwIfInstanceOf(e.getCause(), IOException.class); - throw new IOException("Unexpected exception", e); + } catch (UncheckedIOException e) { + throw new IOException(e); } } } diff --git a/src/main/java/org/cryptomator/cryptofs/DirectoryIdLoader.java b/src/main/java/org/cryptomator/cryptofs/DirectoryIdLoader.java index 627ee94b..c03e85a6 100644 --- a/src/main/java/org/cryptomator/cryptofs/DirectoryIdLoader.java +++ b/src/main/java/org/cryptomator/cryptofs/DirectoryIdLoader.java @@ -1,10 +1,11 @@ package org.cryptomator.cryptofs; -import com.google.common.cache.CacheLoader; +import com.github.benmanes.caffeine.cache.CacheLoader; import javax.inject.Inject; import java.io.IOException; import java.io.InputStream; +import java.io.UncheckedIOException; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; @@ -14,7 +15,7 @@ import java.util.UUID; @CryptoFileSystemScoped -class DirectoryIdLoader extends CacheLoader { +class DirectoryIdLoader implements CacheLoader { private static final int MAX_DIR_ID_LENGTH = 1000; @@ -23,7 +24,7 @@ public DirectoryIdLoader() { } @Override - public String load(Path dirFilePath) throws IOException { + public String load(Path dirFilePath) throws UncheckedIOException { try (FileChannel ch = FileChannel.open(dirFilePath, StandardOpenOption.READ); InputStream in = Channels.newInputStream(ch)) { long size = ch.size(); @@ -39,6 +40,8 @@ public String load(Path dirFilePath) throws IOException { } } catch (NoSuchFileException e) { return UUID.randomUUID().toString(); + } catch (IOException e) { + throw new UncheckedIOException(e); } } diff --git a/src/main/java/org/cryptomator/cryptofs/DirectoryIdProvider.java b/src/main/java/org/cryptomator/cryptofs/DirectoryIdProvider.java index 94c79432..adfe0829 100644 --- a/src/main/java/org/cryptomator/cryptofs/DirectoryIdProvider.java +++ b/src/main/java/org/cryptomator/cryptofs/DirectoryIdProvider.java @@ -8,14 +8,14 @@ *******************************************************************************/ package org.cryptomator.cryptofs; -import java.io.IOException; -import java.nio.file.Path; -import java.util.concurrent.ExecutionException; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; import javax.inject.Inject; - -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.LoadingCache; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; @CryptoFileSystemScoped class DirectoryIdProvider { @@ -26,13 +26,13 @@ class DirectoryIdProvider { @Inject public DirectoryIdProvider(DirectoryIdLoader directoryIdLoader) { - ids = CacheBuilder.newBuilder().maximumSize(MAX_CACHE_SIZE).build(directoryIdLoader); + this.ids = Caffeine.newBuilder().maximumSize(MAX_CACHE_SIZE).build(directoryIdLoader); } public String load(Path dirFilePath) throws IOException { try { return ids.get(dirFilePath); - } catch (ExecutionException e) { + } catch (UncheckedIOException e) { throw new IOException("Failed to load contents of directory file at path " + dirFilePath, e); } } @@ -62,4 +62,5 @@ public void move(Path srcDirFilePath, Path dstDirFilePath) { } } + } diff --git a/src/main/java/org/cryptomator/cryptofs/LongFileNameProvider.java b/src/main/java/org/cryptomator/cryptofs/LongFileNameProvider.java index 94d32b68..8cfd6931 100644 --- a/src/main/java/org/cryptomator/cryptofs/LongFileNameProvider.java +++ b/src/main/java/org/cryptomator/cryptofs/LongFileNameProvider.java @@ -8,10 +8,9 @@ *******************************************************************************/ package org.cryptomator.cryptofs; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import com.google.common.base.Throwables; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; import com.google.common.io.BaseEncoding; import org.cryptomator.cryptolib.common.MessageDigestSupplier; @@ -24,7 +23,6 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.time.Duration; -import java.util.concurrent.ExecutionException; import static java.nio.charset.StandardCharsets.UTF_8; import static org.cryptomator.cryptofs.common.Constants.DEFLATED_FILE_SUFFIX; @@ -39,31 +37,28 @@ public class LongFileNameProvider { private static final Duration MAX_CACHE_AGE = Duration.ofMinutes(1); private final ReadonlyFlag readonlyFlag; - private final LoadingCache longNames; // Maps from c9s paths to inflated filenames + private final Cache longNames; // Maps from c9s paths to inflated filenames @Inject public LongFileNameProvider(ReadonlyFlag readonlyFlag) { this.readonlyFlag = readonlyFlag; - this.longNames = CacheBuilder.newBuilder().expireAfterAccess(MAX_CACHE_AGE).build(new Loader()); + this.longNames = Caffeine.newBuilder().expireAfterAccess(MAX_CACHE_AGE).build(); } - private class Loader extends CacheLoader { - - @Override - public String load(Path c9sPath) throws IOException { - Path longNameFile = c9sPath.resolve(INFLATED_FILE_NAME); - try (SeekableByteChannel ch = Files.newByteChannel(longNameFile, StandardOpenOption.READ)) { - if (ch.size() > MAX_FILENAME_BUFFER_SIZE) { - throw new IOException("Unexpectedly large file: " + longNameFile); - } - assert ch.size() <= MAX_FILENAME_BUFFER_SIZE; - ByteBuffer buf = ByteBuffer.allocate((int) ch.size()); - ch.read(buf); - buf.flip(); - return UTF_8.decode(buf).toString(); + private String load(Path c9sPath) throws UncheckedIOException { + Path longNameFile = c9sPath.resolve(INFLATED_FILE_NAME); + try (SeekableByteChannel ch = Files.newByteChannel(longNameFile, StandardOpenOption.READ)) { + if (ch.size() > MAX_FILENAME_BUFFER_SIZE) { + throw new IOException("Unexpectedly large file: " + longNameFile); } + assert ch.size() <= MAX_FILENAME_BUFFER_SIZE; + ByteBuffer buf = ByteBuffer.allocate((int) ch.size()); + ch.read(buf); + buf.flip(); + return UTF_8.decode(buf).toString(); + } catch (IOException e) { + throw new UncheckedIOException(e); } - } public boolean isDeflated(String possiblyDeflatedFileName) { @@ -72,10 +67,9 @@ public boolean isDeflated(String possiblyDeflatedFileName) { public String inflate(Path c9sPath) throws IOException { try { - return longNames.get(c9sPath); - } catch (ExecutionException e) { - Throwables.throwIfInstanceOf(e.getCause(), IOException.class); - throw new IllegalStateException("Unexpected exception", e); + return longNames.get(c9sPath, this::load); + } catch (UncheckedIOException e) { + throw e.getCause(); // rethrow original to keep exception types such as NoSuchFileException } } diff --git a/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java b/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java index 436dde78..883247df 100644 --- a/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java +++ b/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java @@ -4,8 +4,8 @@ import org.cryptomator.cryptofs.CryptoFileSystemStats; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.fh.BufferPool; -import org.cryptomator.cryptofs.fh.ChunkCache; import org.cryptomator.cryptofs.fh.Chunk; +import org.cryptomator.cryptofs.fh.ChunkCache; import org.cryptomator.cryptofs.fh.ExceptionsDuringWrite; import org.cryptomator.cryptofs.fh.OpenFileModifiedDate; import org.cryptomator.cryptofs.fh.OpenFileSize; @@ -25,6 +25,7 @@ import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.FileTime; import java.time.Instant; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReadWriteLock; @@ -50,7 +51,7 @@ public class CleartextFileChannel extends AbstractFileChannel { private final ExceptionsDuringWrite exceptionsDuringWrite; private final ChannelCloseListener closeListener; private final CryptoFileSystemStats stats; - private boolean mustWriteHeader; + private final AtomicBoolean mustWriteHeader; @Inject public CleartextFileChannel(FileChannel ciphertextFileChannel, FileHeader fileHeader, @MustWriteHeader boolean mustWriteHeader, ReadWriteLock readWriteLock, Cryptor cryptor, ChunkCache chunkCache, BufferPool bufferPool, EffectiveOpenOptions options, @OpenFileSize AtomicLong fileSize, @OpenFileModifiedDate AtomicReference lastModified, Supplier attrViewProvider, ExceptionsDuringWrite exceptionsDuringWrite, ChannelCloseListener closeListener, CryptoFileSystemStats stats) { @@ -70,7 +71,7 @@ public CleartextFileChannel(FileChannel ciphertextFileChannel, FileHeader fileHe if (options.append()) { position = fileSize.get(); } - this.mustWriteHeader = mustWriteHeader; + this.mustWriteHeader = new AtomicBoolean(mustWriteHeader); if (options.createNew() || options.create()) { lastModified.compareAndSet(Instant.EPOCH, Instant.now()); } @@ -93,7 +94,7 @@ protected boolean isReadable() { } @Override - protected synchronized int readLocked(ByteBuffer dst, long position) throws IOException { + protected int readLocked(ByteBuffer dst, long position) throws IOException { int origLimit = dst.limit(); long limitConsideringEof = fileSize.get() - position; if (limitConsideringEof < 1) { @@ -106,10 +107,12 @@ protected synchronized int readLocked(ByteBuffer dst, long position) throws IOEx long pos = position + read; long chunkIndex = pos / payloadSize; // floor by int-truncation int offsetInChunk = (int) (pos % payloadSize); // known to fit in int, because payloadSize is int - ByteBuffer data = chunkCache.get(chunkIndex).data().duplicate().position(offsetInChunk); - int len = min(dst.remaining(), data.remaining()); // known to fit in int, because second argument is int - dst.put(data.limit(data.position() + len)); - read += len; + try (var chunk = chunkCache.getChunk(chunkIndex)) { + ByteBuffer data = chunk.data().duplicate().position(offsetInChunk); + int len = min(dst.remaining(), data.remaining()); // known to fit in int, because second argument is int + dst.put(data.limit(data.position() + len)); + read += len; + } } dst.limit(origLimit); stats.addBytesRead(read); @@ -153,18 +156,18 @@ private long writeLockedInternal(ByteSource src, long position) throws IOExcepti ByteBuffer cleartextChunkData = bufferPool.getCleartextBuffer(); src.copyTo(cleartextChunkData); cleartextChunkData.flip(); - Chunk chunk = new Chunk(cleartextChunkData, true); - chunkCache.set(chunkIndex, chunk); + chunkCache.putChunk(chunkIndex, cleartextChunkData).close(); } else { /* * TODO performance: * We don't actually need to read the current data into the cache. * It would suffice if store the written data and do reading when storing the chunk. */ - Chunk chunk = chunkCache.get(chunkIndex); - chunk.data().limit(Math.max(chunk.data().limit(), offsetInChunk + len)); // increase limit (if needed) - src.copyTo(chunk.data().duplicate().position(offsetInChunk)); // work on duplicate using correct offset - chunk.dirty().set(true); + try (Chunk chunk = chunkCache.getChunk(chunkIndex)) { + chunk.data().limit(Math.max(chunk.data().limit(), offsetInChunk + len)); // increase limit (if needed) + src.copyTo(chunk.data().duplicate().position(offsetInChunk)); // work on duplicate using correct offset + chunk.dirty().set(true); + } } written += len; } @@ -180,11 +183,10 @@ private long writeLockedInternal(ByteSource src, long position) throws IOExcepti } private void writeHeaderIfNeeded() throws IOException { - if (mustWriteHeader) { + if (mustWriteHeader.getAndSet(false)) { LOG.trace("{} - Writing file header.", this); ByteBuffer encryptedHeader = cryptor.fileHeaderCryptor().encryptHeader(fileHeader); ciphertextFileChannel.write(encryptedHeader, 0); - mustWriteHeader = false; // write the header only once! } } @@ -196,11 +198,13 @@ protected void truncateLocked(long newSize) throws IOException { long indexOfLastChunk = (newSize + cleartextChunkSize - 1) / cleartextChunkSize - 1; int sizeOfIncompleteChunk = (int) (newSize % cleartextChunkSize); // known to fit in int, because cleartextChunkSize is int if (sizeOfIncompleteChunk > 0) { - var chunk = chunkCache.get(indexOfLastChunk); - chunk.data().limit(sizeOfIncompleteChunk); - chunk.dirty().set(true); + try (var chunk = chunkCache.getChunk(indexOfLastChunk)) { + chunk.data().limit(sizeOfIncompleteChunk); + chunk.dirty().set(true); + } } long ciphertextFileSize = cryptor.fileHeaderCryptor().headerSize() + cryptor.fileContentCryptor().ciphertextSize(newSize); + chunkCache.flush(); chunkCache.invalidateAll(); // make sure no chunks _after_ newSize exist that would otherwise be written during the next cache eviction ciphertextFileChannel.truncate(ciphertextFileSize); position = min(newSize, position); @@ -230,7 +234,7 @@ private void forceInternal(boolean metaData) throws IOException { private void flush() throws IOException { if (isWritable()) { writeHeaderIfNeeded(); - chunkCache.invalidateAll(); // TODO performance: write chunks but keep them cached + chunkCache.flush(); exceptionsDuringWrite.throwIfPresent(); } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/Chunk.java b/src/main/java/org/cryptomator/cryptofs/fh/Chunk.java index b2af8c70..7cfbf443 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/Chunk.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/Chunk.java @@ -10,8 +10,7 @@ import java.nio.ByteBuffer; import java.util.concurrent.atomic.AtomicBoolean; - -import static java.lang.String.format; +import java.util.concurrent.atomic.AtomicInteger; /** * A chunk of plaintext data. It has these rules: @@ -23,14 +22,18 @@ *
  • When no longer used, the cleartext ByteBuffer may be recycled
  • * */ -public record Chunk(ByteBuffer data, AtomicBoolean dirty) { +public record Chunk(ByteBuffer data, AtomicBoolean dirty, AtomicInteger currentAccesses, Runnable onClose) implements AutoCloseable { - public Chunk(ByteBuffer data, boolean dirty) { - this(data, new AtomicBoolean(dirty)); + public Chunk(ByteBuffer data, boolean dirty, Runnable onClose) { + this(data, new AtomicBoolean(dirty), new AtomicInteger(0), onClose); } public boolean isDirty() { return dirty.get(); } + @Override + public void close() { + onClose.run(); + } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/ChunkCache.java b/src/main/java/org/cryptomator/cryptofs/fh/ChunkCache.java index 215b58a6..199c4cb1 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/ChunkCache.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/ChunkCache.java @@ -1,15 +1,21 @@ package org.cryptomator.cryptofs.fh; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.RemovalNotification; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.google.common.base.Preconditions; import org.cryptomator.cryptofs.CryptoFileSystemStats; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import javax.inject.Inject; import java.io.IOException; import java.io.UncheckedIOException; -import java.util.concurrent.ExecutionException; +import java.nio.ByteBuffer; +import java.nio.channels.NonWritableChannelException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BiFunction; @OpenFileScoped public class ChunkCache { @@ -20,54 +26,172 @@ public class ChunkCache { private final ChunkSaver chunkSaver; private final CryptoFileSystemStats stats; private final BufferPool bufferPool; - private final Cache chunks; + private final ExceptionsDuringWrite exceptionsDuringWrite; + private final ConcurrentMap staleChunks; + private final ConcurrentMap activeChunks; + + /** + * This lock ensures no chunks are passed between stale and active state while flushing, + * as flushing requires iteration over both sets. + */ + private final ReadWriteLock flushLock = new ReentrantReadWriteLock(); @Inject - public ChunkCache(ChunkLoader chunkLoader, ChunkSaver chunkSaver, CryptoFileSystemStats stats, BufferPool bufferPool) { + public ChunkCache(ChunkLoader chunkLoader, ChunkSaver chunkSaver, CryptoFileSystemStats stats, BufferPool bufferPool, ExceptionsDuringWrite exceptionsDuringWrite) { this.chunkLoader = chunkLoader; this.chunkSaver = chunkSaver; this.stats = stats; this.bufferPool = bufferPool; - this.chunks = CacheBuilder.newBuilder() // + this.exceptionsDuringWrite = exceptionsDuringWrite; + this.staleChunks = Caffeine.newBuilder() // .maximumSize(MAX_CACHED_CLEARTEXT_CHUNKS) // - .removalListener(this::removeChunk) // - .build(); + .evictionListener(this::evictStaleChunk) // + .build() // + .asMap(); + this.activeChunks = new ConcurrentHashMap<>(); } - private Chunk loadChunk(long chunkIndex) throws IOException { - stats.addChunkCacheMiss(); + /** + * Overwrites data at the given index and increments the access counter of the returned chunk + * + * @param chunkIndex Which chunk to overwrite + * @param chunkData The cleartext data + * @return The chunk + * @throws IllegalArgumentException If {@code chunkData}'s remaining bytes is not equal to the number of bytes fitting into a chunk + */ + public Chunk putChunk(long chunkIndex, ByteBuffer chunkData) throws IllegalArgumentException { + return activeChunks.compute(chunkIndex, (index, chunk) -> { + // stale chunk for this index is obsolete: + var staleChunk = staleChunks.remove(index); + if (staleChunk != null) { + bufferPool.recycle(staleChunk.data()); + } + // either create completely new chunk or replace all data of existing active chunk: + if (chunk == null) { + chunk = new Chunk(chunkData, true, () -> releaseChunk(chunkIndex)); + } else { + var dst = chunk.data().duplicate().clear(); + Preconditions.checkArgument(chunkData.remaining() == dst.remaining()); + dst.put(chunkData); + chunk.dirty().set(true); + } + chunk.currentAccesses().incrementAndGet(); + return chunk; + }); + } + + /** + * Returns the chunk for the given index, potentially loading the chunk into cache + * + * @param chunkIndex Which chunk to load + * @return The chunk + * @throws IOException If reading or decrypting the chunk failed + */ + public Chunk getChunk(long chunkIndex) throws IOException { + var lock = flushLock.readLock(); + lock.lock(); try { - return chunkLoader.load(chunkIndex); - } catch (AuthenticationFailedException e) { - // TODO provide means to pass an AuthenticationFailedException handler using an OpenOption - throw new IOException("Unauthentic ciphertext in chunk " + chunkIndex, e); + stats.addChunkCacheAccess(); + return activeChunks.compute(chunkIndex, this::acquireInternal); + } catch (UncheckedIOException | AuthenticationFailedException e) { + throw new IOException(e); + } finally { + lock.unlock(); } } - private void removeChunk(RemovalNotification removal) { + private Chunk acquireInternal(Long index, Chunk activeChunk) throws AuthenticationFailedException, UncheckedIOException { + Chunk result = activeChunk; + if (result == null) { + result = staleChunks.remove(index); + assert result == null || result.currentAccesses().get() == 0; + } + if (result == null) { + result = loadChunk(index); + } + + assert result != null; + result.currentAccesses().incrementAndGet(); + return result; + } + + private Chunk loadChunk(long chunkIndex) throws AuthenticationFailedException, UncheckedIOException { + stats.addChunkCacheMiss(); try { - chunkSaver.save(removal.getKey(), removal.getValue()); - bufferPool.recycle(removal.getValue().data()); + return new Chunk(chunkLoader.load(chunkIndex), false, () -> releaseChunk(chunkIndex)); } catch (IOException e) { throw new UncheckedIOException(e); } } - public Chunk get(long chunkIndex) throws IOException { + @SuppressWarnings("resource") + private void releaseChunk(long chunkIndex) { + var lock = flushLock.readLock(); + lock.lock(); try { - stats.addChunkCacheAccess(); - return chunks.get(chunkIndex, () -> loadChunk(chunkIndex)); - } catch (ExecutionException e) { - assert e.getCause() instanceof IOException; // the only checked exception thrown by #loadChunk(long) - throw (IOException) e.getCause(); + activeChunks.compute(chunkIndex, (index, chunk) -> { + assert chunk != null; + var accessCnt = chunk.currentAccesses().decrementAndGet(); + if (accessCnt == 0) { + staleChunks.put(index, chunk); + return null; //chunk is stale, remove from active + } else { + assert accessCnt > 0; + return chunk; //keep active + } + }); + } finally { + lock.unlock(); } } - public void set(long chunkIndex, Chunk data) { - chunks.put(chunkIndex, data); + /** + * Flushes cached data (but keeps them cached). + * @see #invalidateAll() + */ + public void flush() throws IOException { + var lock = flushLock.writeLock(); + lock.lock(); + BiFunction saveUnchecked = (index, chunk) -> { + try { + chunkSaver.save(index, chunk); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return chunk; + }; + try { + activeChunks.replaceAll(saveUnchecked); + staleChunks.replaceAll(saveUnchecked); + } catch (UncheckedIOException e) { + throw new IOException(e); + } finally { + lock.unlock(); + } } + /** + * Removes stale chunks from cache. + */ public void invalidateAll() { - chunks.invalidateAll(); + var lock = flushLock.writeLock(); + lock.lock(); + try { + staleChunks.clear(); + } finally { + lock.unlock(); + } + } + + // visible for testing + void evictStaleChunk(Long index, Chunk chunk, RemovalCause removalCause) { + assert removalCause != RemovalCause.EXPLICIT; // as per spec of Caffeine#evictionListener(RemovalListener) + assert chunk.currentAccesses().get() == 0; + try { + chunkSaver.save(index, chunk); + } catch (IOException | NonWritableChannelException e) { + exceptionsDuringWrite.add(e); + } + bufferPool.recycle(chunk.data()); } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java b/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java index 3524e95a..e2fafb04 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java @@ -26,8 +26,7 @@ public ChunkLoader(Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerH this.bufferPool = bufferPool; } - public Chunk load(Long chunkIndex) throws IOException, AuthenticationFailedException { - stats.addChunkCacheMiss(); + public ByteBuffer load(Long chunkIndex) throws IOException, AuthenticationFailedException { int chunkSize = cryptor.fileContentCryptor().ciphertextChunkSize(); long ciphertextPos = chunkIndex * chunkSize + cryptor.fileHeaderCryptor().headerSize(); ByteBuffer ciphertextBuf = bufferPool.getCiphertextBuffer(); @@ -42,7 +41,7 @@ public Chunk load(Long chunkIndex) throws IOException, AuthenticationFailedExcep cleartextBuf.flip(); stats.addBytesDecrypted(cleartextBuf.remaining()); } - return new Chunk(cleartextBuf, false); + return cleartextBuf; } finally { bufferPool.recycle(ciphertextBuf); } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/ChunkSaver.java b/src/main/java/org/cryptomator/cryptofs/fh/ChunkSaver.java index dbb798b3..02504f4b 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/ChunkSaver.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/ChunkSaver.java @@ -13,16 +13,14 @@ class ChunkSaver { private final Cryptor cryptor; private final ChunkIO ciphertext; private final FileHeaderHolder headerHolder; - private final ExceptionsDuringWrite exceptionsDuringWrite; private final CryptoFileSystemStats stats; private final BufferPool bufferPool; @Inject - public ChunkSaver(Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, ExceptionsDuringWrite exceptionsDuringWrite, CryptoFileSystemStats stats, BufferPool bufferPool) { + public ChunkSaver(Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) { this.cryptor = cryptor; this.ciphertext = ciphertext; this.headerHolder = headerHolder; - this.exceptionsDuringWrite = exceptionsDuringWrite; this.stats = stats; this.bufferPool = bufferPool; } @@ -30,18 +28,17 @@ public ChunkSaver(Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHo public void save(long chunkIndex, Chunk chunkData) throws IOException { if (chunkData.isDirty()) { long ciphertextPos = chunkIndex * cryptor.fileContentCryptor().ciphertextChunkSize() + cryptor.fileHeaderCryptor().headerSize(); - ByteBuffer cleartextBuf = chunkData.data().duplicate(); + ByteBuffer cleartextBuf = chunkData.data().asReadOnlyBuffer(); stats.addBytesEncrypted(cleartextBuf.remaining()); ByteBuffer ciphertextBuf = bufferPool.getCiphertextBuffer(); try { cryptor.fileContentCryptor().encryptChunk(cleartextBuf, ciphertextBuf, chunkIndex, headerHolder.get()); ciphertextBuf.flip(); ciphertext.write(ciphertextBuf, ciphertextPos); - } catch (IOException e) { - exceptionsDuringWrite.add(e); + chunkData.dirty().set(false); } finally { bufferPool.recycle(ciphertextBuf); - } // unchecked exceptions will be propagated to the thread causing removal + } } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/ExceptionsDuringWrite.java b/src/main/java/org/cryptomator/cryptofs/fh/ExceptionsDuringWrite.java index 1586a6b7..b0cf0931 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/ExceptionsDuringWrite.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/ExceptionsDuringWrite.java @@ -18,7 +18,7 @@ public class ExceptionsDuringWrite { public ExceptionsDuringWrite() { } - public synchronized void add(IOException e) { + public synchronized void add(Exception e) { e.addSuppressed(e); } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java b/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java index 8849392d..34abc1e1 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java @@ -39,11 +39,8 @@ public FileHeader get() { public FileHeader createNew() { LOG.trace("Generating file header for {}", path.get()); FileHeader newHeader = cryptor.fileHeaderCryptor().create(); - if (header.compareAndSet(null, newHeader)) { - return newHeader; - } else { - return header.get(); - } + header.set(newHeader); + return newHeader; } public FileHeader loadExisting(FileChannel ch) throws IOException { diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java index 1fa916d7..4186e248 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java @@ -51,7 +51,7 @@ public Optional get(Path ciphertextPath) { } /** - * Opens a file to {@link OpenCryptoFile#newFileChannel(EffectiveOpenOptions) retrieve a FileChannel}. If this file is already opened, a shared instance is returned. + * 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 ciphertextPath Path of the file to open diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java index cfc773a1..93216d8b 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java @@ -8,6 +8,7 @@ *******************************************************************************/ package org.cryptomator.cryptofs; +import com.google.common.jimfs.Configuration; import com.google.common.jimfs.Jimfs; import org.cryptomator.cryptofs.util.ByteBuffers; import org.cryptomator.cryptolib.api.Masterkey; @@ -17,7 +18,9 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.condition.EnabledOnOs; @@ -30,6 +33,7 @@ import org.mockito.Mockito; import java.io.IOException; +import java.lang.invoke.MethodHandles; import java.net.URI; import java.nio.ByteBuffer; import java.nio.channels.Channels; @@ -42,7 +46,12 @@ import java.nio.file.attribute.FileTime; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Arrays; import java.util.List; +import java.util.Random; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; import java.util.stream.Stream; import static java.lang.Math.min; @@ -225,18 +234,39 @@ public void testReadWhileStillWriting() throws IOException { // tests https://github.com/cryptomator/cryptofs/issues/48 @Test - public void testTruncateExistingWhileStillOpen() throws IOException { - try (FileChannel ch1 = FileChannel.open(file, CREATE_NEW, WRITE)) { - ch1.write(StandardCharsets.UTF_8.encode("goodbye world"), 0); - ch1.force(true); // will generate a file header - try (FileChannel ch2 = FileChannel.open(file, CREATE, WRITE, TRUNCATE_EXISTING)) { // reuse existing file header, but will not re-write it - ch2.write(StandardCharsets.UTF_8.encode("hello world"), 0); + @DisplayName("writing from second channel while first is still open") + public void testWriteFromSecondChannelWhileStillOpen() throws IOException { + try (var ch1 = FileChannel.open(file, CREATE_NEW, WRITE)) { + ch1.write(StandardCharsets.UTF_8.encode("howdy world"), 0); + ch1.force(true); + try (var ch2 = FileChannel.open(file, CREATE, WRITE)) { // reuse existing file header, but will not re-write it + ch2.write(StandardCharsets.UTF_8.encode("hello"), 0); } } - try (FileChannel ch1 = FileChannel.open(file, READ)) { - ByteBuffer buf = ByteBuffer.allocate((int) ch1.size()); - ch1.read(buf); + try (var ch3 = FileChannel.open(file, READ)) { + ByteBuffer buf = ByteBuffer.allocate((int) ch3.size()); + ch3.read(buf); + Assertions.assertArrayEquals("hello world".getBytes(), buf.array()); + } + } + + // tests https://github.com/cryptomator/cryptofs/issues/160 + @Test + @DisplayName("TRUNCATE_EXISTING leads to new file header") + public void testNewFileHeaderWhenTruncateExisting() throws IOException { + try (var ch1 = FileChannel.open(file, CREATE_NEW, WRITE)) { + ch1.write(StandardCharsets.UTF_8.encode("this content will be truncated soon"), 0); + ch1.force(true); + try (var ch2 = FileChannel.open(file, CREATE, WRITE, TRUNCATE_EXISTING)) { // re-roll file header + ch2.write(StandardCharsets.UTF_8.encode("hello"), 0); + } + ch1.write(StandardCharsets.UTF_8.encode(" world"), 5); // should use new file key + } + + try (var ch3 = FileChannel.open(file, READ)) { + ByteBuffer buf = ByteBuffer.allocate((int) ch3.size()); + ch3.read(buf); Assertions.assertArrayEquals("hello world".getBytes(), buf.array()); } } @@ -463,6 +493,60 @@ public void testAppend(int dataSize) throws IOException { } } + @RepeatedTest(100) + public void testConcurrentRead() throws IOException, InterruptedException { + // prepare test data: + var content = new byte[10_000_000]; + for (int i = 0; i < 100_000; i++) { + byte b = (byte) (i % 255); + Arrays.fill(content, i * 100, (i + 1) * 100, b); + } + + try (var ch = FileChannel.open(file, CREATE, WRITE, TRUNCATE_EXISTING)) { + ch.write(ByteBuffer.wrap(content)); + } + + // read concurrently from same file channel: + var numThreads = 100; + var rnd = new Random(42L); + var results = new int[numThreads]; + var resultsHandle = MethodHandles.arrayElementVarHandle(int[].class); + Arrays.fill(results, 42); + var executor = Executors.newCachedThreadPool(); + try (var ch = FileChannel.open(file, READ)) { + for (int i = 0; i < numThreads; i++) { + int t = i; + int num = rnd.nextInt(50_000); + int pos = rnd.nextInt(400_000); + executor.submit(() -> { + ByteBuffer buf = ByteBuffer.allocate(num); + try { + int read = ch.read(buf, pos); + if (read != num) { + System.out.println("thread " + t + " read " + pos + " - " + (pos + num)); + resultsHandle.setOpaque(results, t, -1); // ERROR invalid number of bytes + } else if (Arrays.equals(content, pos, pos + num, buf.array(), 0, read)) { + resultsHandle.setOpaque(results, t, 0); // SUCCESS + } else { + System.out.println("thread " + t + " read " + pos + " - " + (pos + num)); + resultsHandle.setOpaque(results, t, -2); // ERROR invalid content + } + } catch (IOException e) { + e.printStackTrace(); + resultsHandle.setOpaque(results, t, -3); // ERROR I/O error + } + }); + } + executor.shutdown(); + boolean allTasksFinished = executor.awaitTermination(10, TimeUnit.SECONDS); + Assertions.assertTrue(allTasksFinished); + } + + Assertions.assertAll(IntStream.range(0, numThreads).mapToObj(t -> { + return () -> Assertions.assertEquals(0, resultsHandle.getOpaque(results, t), "thread " + t + " unsuccessful"); + })); + } + } } diff --git a/src/test/java/org/cryptomator/cryptofs/DirectoryIdLoaderTest.java b/src/test/java/org/cryptomator/cryptofs/DirectoryIdLoaderTest.java index 3943207a..6141325f 100644 --- a/src/test/java/org/cryptomator/cryptofs/DirectoryIdLoaderTest.java +++ b/src/test/java/org/cryptomator/cryptofs/DirectoryIdLoaderTest.java @@ -7,6 +7,7 @@ import org.mockito.Mockito; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.file.FileSystem; @@ -83,10 +84,10 @@ public void testIOExceptionWhenExistingFileIsEmpty() throws IOException { when(provider.newFileChannel(eq(dirFilePath), any())).thenReturn(channel); when(channel.size()).thenReturn(0l); - IOException e = Assertions.assertThrows(IOException.class, () -> { + UncheckedIOException e = Assertions.assertThrows(UncheckedIOException.class, () -> { inTest.load(dirFilePath); }); - MatcherAssert.assertThat(e.getMessage(), containsString("Invalid, empty directory file")); + MatcherAssert.assertThat(e.getCause().getMessage(), containsString("Invalid, empty directory file")); } @Test @@ -95,10 +96,10 @@ public void testIOExceptionWhenExistingFileIsTooLarge() throws IOException { when(provider.newFileChannel(eq(dirFilePath), any())).thenReturn(channel); when(channel.size()).thenReturn((long) Integer.MAX_VALUE); - IOException e = Assertions.assertThrows(IOException.class, () -> { + UncheckedIOException e = Assertions.assertThrows(UncheckedIOException.class, () -> { inTest.load(dirFilePath); }); - MatcherAssert.assertThat(e.getMessage(), containsString("Unexpectedly large directory file")); + MatcherAssert.assertThat(e.getCause().getMessage(), containsString("Unexpectedly large directory file")); } } diff --git a/src/test/java/org/cryptomator/cryptofs/DirectoryIdProviderTest.java b/src/test/java/org/cryptomator/cryptofs/DirectoryIdProviderTest.java index 8525403c..33c6d04d 100644 --- a/src/test/java/org/cryptomator/cryptofs/DirectoryIdProviderTest.java +++ b/src/test/java/org/cryptomator/cryptofs/DirectoryIdProviderTest.java @@ -9,6 +9,7 @@ import org.mockito.verification.VerificationMode; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Path; import java.util.concurrent.ExecutionException; @@ -38,14 +39,14 @@ public void testLoadInvokesLoader() throws IOException { } @Test - public void testIOExceptionFromLoaderIsWrappedAndRethrown() throws IOException { + public void testIOExceptionFromLoaderIsWrappedAndRethrown() { IOException originalIoException = new IOException(); - when(loader.load(aPath)).thenThrow(originalIoException); + when(loader.load(aPath)).thenThrow(new UncheckedIOException(originalIoException)); IOException e = Assertions.assertThrows(IOException.class, () -> { inTest.load(aPath); }); - Assertions.assertTrue(e.getCause() instanceof ExecutionException); + Assertions.assertTrue(e.getCause() instanceof UncheckedIOException); Assertions.assertEquals(originalIoException, e.getCause().getCause()); } diff --git a/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java b/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java index a2dfb966..fe9ac96f 100644 --- a/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java +++ b/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java @@ -76,7 +76,8 @@ public class CleartextFileChannelTest { public void setUp() throws IOException { when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); when(cryptor.fileContentCryptor()).thenReturn(fileContentCryptor); - when(chunkCache.get(Mockito.anyLong())).then(invocation -> new Chunk(ByteBuffer.allocate(100), false)); + when(chunkCache.getChunk(Mockito.anyLong())).then(invocation -> new Chunk(ByteBuffer.allocate(100), false, () -> {})); + when(chunkCache.putChunk(Mockito.anyLong(), Mockito.any())).thenAnswer(invocation -> new Chunk(invocation.getArgument(1), true, () -> {})); when(bufferPool.getCleartextBuffer()).thenAnswer(invocation -> ByteBuffer.allocate(100)); when(fileHeaderCryptor.headerSize()).thenReturn(50); when(fileContentCryptor.cleartextChunkSize()).thenReturn(100); @@ -176,7 +177,7 @@ public void testForceInvalidatesChunkCacheWhenWritable() throws IOException { inTest.force(false); - verify(chunkCache).invalidateAll(); + verify(chunkCache).flush(); } @Test @@ -189,7 +190,7 @@ public void testForceRethrowsExceptionsDuringWrite() throws IOException { }); Assertions.assertEquals("exception during write", e.getMessage()); - verify(chunkCache).invalidateAll(); + verify(chunkCache).flush(); } @Test @@ -354,10 +355,10 @@ public void testReadFromMultipleChunks() throws IOException { inTest.read(buf, 5_000_000_000l); InOrder inOrder = Mockito.inOrder(chunkCache, chunkCache, chunkCache, chunkCache); - inOrder.verify(chunkCache).get(0l); // A - inOrder.verify(chunkCache).get(1l); // B - inOrder.verify(chunkCache).get(2l); // B - inOrder.verify(chunkCache).get(50_000_000l); // C + inOrder.verify(chunkCache).getChunk(0l); // A + inOrder.verify(chunkCache).getChunk(1l); // B + inOrder.verify(chunkCache).getChunk(2l); // B + inOrder.verify(chunkCache).getChunk(50_000_000l); // C inOrder.verifyNoMoreInteractions(); } @@ -407,9 +408,9 @@ public void testWriteToMultipleChunks() throws IOException { inTest.write(buf3, 5000); InOrder inOrder = Mockito.inOrder(chunkCache, chunkCache, chunkCache); - inOrder.verify(chunkCache).get(0l); // A - inOrder.verify(chunkCache).set(Mockito.eq(1l), Mockito.any()); // B - inOrder.verify(chunkCache).set(Mockito.eq(50l), Mockito.any()); // C + inOrder.verify(chunkCache).getChunk(0l); // A + inOrder.verify(chunkCache).putChunk(Mockito.eq(1l), Mockito.any()); // B + inOrder.verify(chunkCache).putChunk(Mockito.eq(50l), Mockito.any()); // C inOrder.verifyNoMoreInteractions(); } @@ -437,9 +438,9 @@ public void testWriteIncrementsPositionByAmountWritten() throws IOException { Assertions.assertEquals(110, written); Assertions.assertEquals(205, inTest.size()); - verify(chunkCache).get(0l); - verify(chunkCache).set(Mockito.eq(1l), Mockito.any()); - verify(chunkCache).get(2l); + verify(chunkCache).getChunk(0l); + verify(chunkCache).putChunk(Mockito.eq(1l), Mockito.any()); + verify(chunkCache).getChunk(2l); } @Test diff --git a/src/test/java/org/cryptomator/cryptofs/fh/ChunkCacheTest.java b/src/test/java/org/cryptomator/cryptofs/fh/ChunkCacheTest.java index f62dd41b..d4cf0615 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/ChunkCacheTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/ChunkCacheTest.java @@ -1,13 +1,22 @@ package org.cryptomator.cryptofs.fh; +import com.github.benmanes.caffeine.cache.RemovalCause; import org.cryptomator.cryptofs.CryptoFileSystemStats; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.file.AccessDeniedException; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -20,147 +29,269 @@ public class ChunkCacheTest { private final ChunkSaver chunkSaver = mock(ChunkSaver.class); private final CryptoFileSystemStats stats = mock(CryptoFileSystemStats.class); private final BufferPool bufferPool = mock(BufferPool.class); - private final ChunkCache inTest = new ChunkCache(chunkLoader, chunkSaver, stats, bufferPool); + private final ExceptionsDuringWrite exceptionsDuringWrite = mock(ExceptionsDuringWrite.class); + private ChunkCache inTest; + + @BeforeEach + public void setup() { + inTest = new ChunkCache(chunkLoader, chunkSaver, stats, bufferPool, exceptionsDuringWrite); + } @Test + @DisplayName("getChunk() invokes chunkLoader.load() on cache miss") public void testGetInvokesLoaderIfEntryNotInCache() throws IOException, AuthenticationFailedException { long index = 42L; - Chunk chunk = mock(Chunk.class); - when(chunkLoader.load(index)).thenReturn(chunk); + var data = ByteBuffer.allocate(0); + when(chunkLoader.load(index)).thenReturn(data); + + inTest.getChunk(index); - Assertions.assertSame(chunk, inTest.get(index)); + verify(chunkLoader).load(index); verify(stats).addChunkCacheAccess(); + verify(stats).addChunkCacheMiss(); } @Test - public void testGetDoesNotInvokeLoaderIfEntryInCacheFromPreviousGet() throws IOException, AuthenticationFailedException { - long index = 42L; - Chunk chunk = mock(Chunk.class); - when(chunkLoader.load(index)).thenReturn(chunk); - inTest.get(index); + @DisplayName("getChunk() propagates AuthenticationFailedException from loader") + public void testGetRethrowsAuthenticationFailedExceptionFromLoader() throws IOException, AuthenticationFailedException { + AuthenticationFailedException authenticationFailedException = new AuthenticationFailedException("Foo"); + when(chunkLoader.load(42L)).thenThrow(authenticationFailedException); - Assertions.assertSame(chunk, inTest.get(index)); - verify(stats, Mockito.times(2)).addChunkCacheAccess(); - verify(chunkLoader).load(index); + IOException e = Assertions.assertThrows(IOException.class, () -> { + inTest.getChunk(42L); + }); + Assertions.assertSame(authenticationFailedException, e.getCause()); } @Test - public void testGetDoesNotInvokeLoaderIfEntryInCacheFromPreviousSet() throws IOException { - long index = 42L; - Chunk chunk = mock(Chunk.class); - inTest.set(index, chunk); + @DisplayName("getChunk() rethrows RuntimeException from loader") + public void testGetThrowsUncheckedExceptionFromLoader() throws IOException, AuthenticationFailedException { + RuntimeException uncheckedException = new RuntimeException(); + when(chunkLoader.load(42L)).thenThrow(uncheckedException); - Assertions.assertSame(chunk, inTest.get(index)); - verify(stats).addChunkCacheAccess(); + Assertions.assertThrows(RuntimeException.class, () -> { + inTest.getChunk(42); + }); } @Test - public void testGetInvokesSaverIfMaxEntriesInCacheAreReachedAndAnEntryNotInCacheIsRequested() throws IOException, AuthenticationFailedException { - long firstIndex = 42L; - long indexNotInCache = 40L; - Chunk chunk = mock(Chunk.class); - inTest.set(firstIndex, chunk); - for (int i = 1; i < ChunkCache.MAX_CACHED_CLEARTEXT_CHUNKS; i++) { - inTest.set(firstIndex + i, mock(Chunk.class)); + @DisplayName("getChunk() propagates IOException from loader") + public void testGetRethrowsIOExceptionFromLoader() throws IOException, AuthenticationFailedException { + when(chunkLoader.load(42L)).thenThrow(new IOException()); + + Assertions.assertThrows(IOException.class, () -> { + inTest.getChunk(42L); + }); + } + + @Nested + @DisplayName("With active chunks [1] and stale chunks [42, 43, 44, 45, 46]") + public class Prepopulated { + + private Chunk activeChunk1; + private Chunk staleChunk42; + private Chunk staleChunk43; + private Chunk staleChunk44; + private Chunk staleChunk45; + private Chunk staleChunk46; + + @BeforeEach + public void setup() { + Assumptions.assumeTrue(ChunkCache.MAX_CACHED_CLEARTEXT_CHUNKS == 5); + + activeChunk1 = inTest.putChunk(1L, ByteBuffer.allocate(0)); + Assertions.assertEquals(1, activeChunk1.currentAccesses().get()); + + staleChunk42 = inTest.putChunk(42L, ByteBuffer.allocate(0)); + staleChunk43 = inTest.putChunk(43L, ByteBuffer.allocate(0)); + staleChunk44 = inTest.putChunk(44L, ByteBuffer.allocate(0)); + staleChunk45 = inTest.putChunk(45L, ByteBuffer.allocate(0)); + staleChunk46 = inTest.putChunk(46L, ByteBuffer.allocate(0)); + staleChunk42.close(); + staleChunk43.close(); + staleChunk44.close(); + staleChunk45.close(); + staleChunk46.close(); + Assertions.assertEquals(0, staleChunk42.currentAccesses().get()); + Assertions.assertEquals(0, staleChunk43.currentAccesses().get()); + Assertions.assertEquals(0, staleChunk44.currentAccesses().get()); + Assertions.assertEquals(0, staleChunk45.currentAccesses().get()); + Assertions.assertEquals(0, staleChunk46.currentAccesses().get()); } - when(chunkLoader.load(indexNotInCache)).thenReturn(mock(Chunk.class)); - inTest.get(indexNotInCache); + @Test + @DisplayName("getChunk() does not invoke chunkLoader.load() and returns active chunk") + public void testGetChunkActive() throws IOException { + var chunk = inTest.getChunk(1L); - verify(stats).addChunkCacheAccess(); - verify(chunkSaver).save(firstIndex, chunk); - verify(bufferPool).recycle(chunk.data()); - verifyNoMoreInteractions(chunkSaver); - } + Assertions.assertSame(activeChunk1, chunk); + Assertions.assertEquals(2, chunk.currentAccesses().get()); + verify(stats).addChunkCacheAccess(); + verifyNoMoreInteractions(stats); + verifyNoMoreInteractions(chunkLoader); + } - @Test - public void testGetInvokesSaverIfMaxEntriesInCacheAreReachedAndAnEntryNotInCacheIsSet() throws IOException { - long firstIndex = 42L; - long indexNotInCache = 40L; - Chunk chunk = mock(Chunk.class); - inTest.set(firstIndex, chunk); - for (int i = 1; i < ChunkCache.MAX_CACHED_CLEARTEXT_CHUNKS; i++) { - inTest.set(firstIndex + i, mock(Chunk.class)); + @Test + @DisplayName("getChunk() does not invoke chunkLoader.load() and returns stale chunk") + public void testGetChunkStale() throws IOException { + var chunk = inTest.getChunk(42L); + + Assertions.assertSame(staleChunk42, chunk); + Assertions.assertEquals(1, chunk.currentAccesses().get()); + verify(stats).addChunkCacheAccess(); + verifyNoMoreInteractions(stats); + verifyNoMoreInteractions(chunkLoader); } - inTest.set(indexNotInCache, mock(Chunk.class)); + @Test + @DisplayName("chunk.close() keeps chunk active if access count > 1") + public void testClosingActiveChunkThatIsReferencedTwice() throws IOException, AuthenticationFailedException { + try (var chunk = inTest.getChunk(1L)) { + Assertions.assertSame(activeChunk1, chunk); + Assertions.assertEquals(2, activeChunk1.currentAccesses().get()); + } // close - verify(chunkSaver).save(firstIndex, chunk); - verify(bufferPool).recycle(chunk.data()); - verifyNoMoreInteractions(chunkSaver); - } + Assertions.assertEquals(1, activeChunk1.currentAccesses().get()); + verifyNoMoreInteractions(chunkSaver); + verifyNoMoreInteractions(bufferPool); + } - @Test - public void testGetInvokesSaverIfMaxEntriesInCacheAreReachedAndAnEntryInCacheIsSet() throws IOException { - // TODO markuskreusch: this behaviour isn't actually needed, maybe we can somehow prevent saving in such situations? - long firstIndex = 42L; - Chunk chunk = mock(Chunk.class); - inTest.set(firstIndex, chunk); - for (int i = 1; i < ChunkCache.MAX_CACHED_CLEARTEXT_CHUNKS; i++) { - inTest.set(firstIndex + i, mock(Chunk.class)); + @RepeatedTest(30) + @DisplayName("chunk.close() triggers eviction of LRU stale chunk") + public void testClosingActiveChunkTriggersEvictionOfStaleChunk() throws IOException, AuthenticationFailedException { + var cdl = new CountDownLatch(1); + Mockito.doAnswer(invocation -> { + cdl.countDown(); + return null; + }).when(chunkSaver).save(Mockito.anyLong(), Mockito.any()); + + activeChunk1.close(); + + Assertions.assertTimeoutPreemptively(Duration.ofMillis(100), () -> { + cdl.await(); + }); + verify(chunkSaver).save(42L, staleChunk42); + verify(bufferPool).recycle(staleChunk42.data()); + verifyNoMoreInteractions(chunkSaver); } - inTest.set(firstIndex, mock(Chunk.class)); + @Test + @DisplayName("flush() saves all stale chunks") + public void testFlushInvokesSaverForAllStaleChunks() throws IOException, AuthenticationFailedException { + inTest.flush(); - verify(chunkSaver).save(firstIndex, chunk); - verify(bufferPool).recycle(chunk.data()); - verifyNoMoreInteractions(chunkSaver); - } + verify(chunkSaver).save(42L, staleChunk42); + verify(chunkSaver).save(43L, staleChunk43); + verify(chunkSaver).save(44L, staleChunk44); + verify(chunkSaver).save(45L, staleChunk45); + verify(chunkSaver).save(46L, staleChunk46); + } - @Test - public void testGetRethrowsAuthenticationFailedExceptionFromLoader() throws IOException, AuthenticationFailedException { - long index = 42L; - AuthenticationFailedException authenticationFailedException = new AuthenticationFailedException("Foo"); - when(chunkLoader.load(index)).thenThrow(authenticationFailedException); + @Test + @DisplayName("flush() saves all active chunks") + public void testFlushInvokesSaverForAllActiveChunks() throws IOException, AuthenticationFailedException { + try (var activeChunk2 = inTest.putChunk(2L, ByteBuffer.allocate(0))) { + inTest.flush(); - IOException e = Assertions.assertThrows(IOException.class, () -> { - inTest.get(index); - }); - Assertions.assertSame(authenticationFailedException, e.getCause()); - } + verify(chunkSaver).save(1L, activeChunk1); + verify(chunkSaver).save(2L, activeChunk2); + } + } - @Test - public void testGetThrowsUncheckedExceptionFromLoader() throws IOException, AuthenticationFailedException { - long index = 42L; - RuntimeException uncheckedException = new RuntimeException(); - when(chunkLoader.load(index)).thenThrow(uncheckedException); + @Test + @DisplayName("flush() does not evict cached chunks") + public void testFlushKeepsItemInCache() throws IOException, AuthenticationFailedException { + inTest.flush(); + + Assertions.assertSame(activeChunk1, inTest.getChunk(1L)); + Assertions.assertSame(staleChunk42, inTest.getChunk(42L)); + verifyNoMoreInteractions(chunkLoader); + } + + @Test + @DisplayName("flush() does not evict cached chunks despite I/O error") + public void testFlushKeepsItemInCacheDespiteIOException() throws IOException, AuthenticationFailedException { + Mockito.doThrow(new AccessDeniedException("")).when(chunkSaver).save(42L, staleChunk42); + + Assertions.assertThrows(IOException.class, () -> inTest.flush()); + + Assertions.assertSame(activeChunk1, inTest.getChunk(1L)); + Assertions.assertSame(staleChunk42, inTest.getChunk(42L)); + verifyNoMoreInteractions(chunkLoader); + } + + @Test + @DisplayName("invalidateAll() flushes stale chunks but keeps active chunks") + public void testInvalidateAll() throws IOException, AuthenticationFailedException { + when(chunkLoader.load(Mockito.anyLong())).thenReturn(ByteBuffer.allocate(0)); + + inTest.invalidateAll(); + + Assertions.assertSame(activeChunk1, inTest.getChunk(1L)); + Assertions.assertNotSame(staleChunk42, inTest.getChunk(42L)); + Assertions.assertNotSame(staleChunk43, inTest.getChunk(43L)); + Assertions.assertNotSame(staleChunk44, inTest.getChunk(44L)); + Assertions.assertNotSame(staleChunk45, inTest.getChunk(45L)); + Assertions.assertNotSame(staleChunk46, inTest.getChunk(46L)); + verify(chunkLoader).load(42L); + verify(chunkLoader).load(43L); + verify(chunkLoader).load(44L); + verify(chunkLoader).load(45L); + verify(chunkLoader).load(46L); + verifyNoMoreInteractions(chunkLoader); + } + + @Test + @DisplayName("putChunk() returns active chunk if already present") + public void testPutChunkReturnsActiveChunk() { + activeChunk1.dirty().set(false); + + var chunk = inTest.putChunk(1L, ByteBuffer.allocate(0)); + + Assertions.assertSame(activeChunk1, chunk); + Assertions.assertEquals(2, chunk.currentAccesses().get()); + Assertions.assertTrue(chunk.isDirty()); + } + + @Test + @DisplayName("putChunk() recycles stale chunk if present") + public void testPutChunkRecyclesStaleChunk() { + var chunk = inTest.putChunk(42L, ByteBuffer.allocate(0)); + + Assertions.assertNotSame(staleChunk42, chunk); + Assertions.assertEquals(1, chunk.currentAccesses().get()); + Assertions.assertTrue(chunk.isDirty()); + verify(bufferPool).recycle(staleChunk42.data()); + } + + @Test + @DisplayName("putChunk() returns new chunk if neither stale nor active") + public void testPutChunkReturnsNewChunk() { + var chunk = inTest.putChunk(100L, ByteBuffer.allocate(0)); + + Assertions.assertNotSame(activeChunk1, chunk); + Assertions.assertNotSame(staleChunk42, chunk); + Assertions.assertNotSame(staleChunk43, chunk); + Assertions.assertNotSame(staleChunk44, chunk); + Assertions.assertNotSame(staleChunk45, chunk); + Assertions.assertNotSame(staleChunk46, chunk); + Assertions.assertEquals(1, chunk.currentAccesses().get()); + Assertions.assertTrue(chunk.isDirty()); + } - RuntimeException e = Assertions.assertThrows(RuntimeException.class, () -> { - inTest.get(index); - }); - Assertions.assertSame(uncheckedException, e.getCause()); - } - @Test - public void testInvalidateAllInvokesSaverForAllEntriesInCache() throws IOException, AuthenticationFailedException { - long index = 42L; - long index2 = 43L; - Chunk chunk1 = mock(Chunk.class); - Chunk chunk2 = mock(Chunk.class); - when(chunk1.data()).thenReturn(ByteBuffer.allocate(42)); - when(chunk2.data()).thenReturn(ByteBuffer.allocate(23)); - when(chunkLoader.load(index)).thenReturn(chunk1); - inTest.get(index); - inTest.set(index2, chunk2); - - inTest.invalidateAll(); - - verify(chunkSaver).save(index, chunk1); - verify(bufferPool).recycle(chunk1.data()); - verify(chunkSaver).save(index2, chunk2); - verify(bufferPool).recycle(chunk2.data()); } @Test - public void testGetRethrowsIOExceptionFromLoader() throws IOException, AuthenticationFailedException { - long index = 42L; + @DisplayName("IOException during eviction of stale chunks is stored to exceptionsDuringWrite") + public void testIOExceptionsDuringWriteAreAddedToExceptionsDuringWrite() throws IOException { IOException ioException = new IOException(); - when(chunkLoader.load(index)).thenThrow(ioException); + Chunk chunk = new Chunk(ByteBuffer.allocate(0), true, () -> {}); + Mockito.doThrow(ioException).when(chunkSaver).save(42L, chunk); - IOException e = Assertions.assertThrows(IOException.class, () -> { - inTest.get(index); - }); - Assertions.assertSame(ioException, e); + inTest.evictStaleChunk(42L, chunk, RemovalCause.EXPIRED); + + verify(exceptionsDuringWrite).add(ioException); } } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java b/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java index 8d05b987..dac1d303 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java @@ -65,12 +65,11 @@ public void testLoadReturnsEmptyChunkAfterEOF() throws IOException, Authenticati long chunkOffset = chunkIndex * CIPHERTEXT_CHUNK_SIZE + HEADER_SIZE; when(chunkIO.read(argThat(hasAtLeastRemaining(CIPHERTEXT_CHUNK_SIZE)), eq(chunkOffset))).thenReturn(-1); - Chunk chunk = inTest.load(chunkIndex); + var data = inTest.load(chunkIndex); - verify(stats).addChunkCacheMiss(); verify(bufferPool).recycle(argThat(ByteBufferMatcher.hasCapacity(CIPHERTEXT_CHUNK_SIZE))); - Assertions.assertEquals(0, chunk.data().remaining()); - Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, chunk.data().capacity()); + Assertions.assertEquals(0, data.remaining()); + Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, data.capacity()); } @Test @@ -89,14 +88,13 @@ public void testLoadReturnsDecryptedDataInsideFile() throws IOException, Authent Mockito.any(), eq(chunkIndex), eq(header), eq(true) // ); - Chunk chunk = inTest.load(chunkIndex); + var data = inTest.load(chunkIndex); - verify(stats).addChunkCacheMiss(); - verify(stats).addBytesDecrypted(chunk.data().remaining()); + verify(stats).addBytesDecrypted(data.remaining()); verify(bufferPool).recycle(argThat(ByteBufferMatcher.hasCapacity(CIPHERTEXT_CHUNK_SIZE))); - assertThat(chunk.data(), contains(decryptedData.get())); - Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, chunk.data().remaining()); - Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, chunk.data().capacity()); + assertThat(data, contains(decryptedData.get())); + Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, data.remaining()); + Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, data.capacity()); } @Test @@ -115,14 +113,13 @@ public void testLoadReturnsDecrytedDataNearEOF() throws IOException, Authenticat any(ByteBuffer.class), eq(chunkIndex), eq(header), eq(true) // ); - Chunk chunk = inTest.load(chunkIndex); + var data = inTest.load(chunkIndex); - verify(stats).addChunkCacheMiss(); - verify(stats).addBytesDecrypted(chunk.data().remaining()); + verify(stats).addBytesDecrypted(data.remaining()); verify(bufferPool).recycle(argThat(ByteBufferMatcher.hasCapacity(CIPHERTEXT_CHUNK_SIZE))); - assertThat(chunk.data(), contains(decryptedData.get())); - Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE - 3, chunk.data().remaining()); - Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, chunk.data().capacity()); + assertThat(data, contains(decryptedData.get())); + Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE - 3, data.remaining()); + Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, data.capacity()); } private Answer fillBufferWith(byte value, int amount) { diff --git a/src/test/java/org/cryptomator/cryptofs/fh/ChunkSaverTest.java b/src/test/java/org/cryptomator/cryptofs/fh/ChunkSaverTest.java index e3f9e885..a5cf7717 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/ChunkSaverTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/ChunkSaverTest.java @@ -6,6 +6,7 @@ import org.cryptomator.cryptolib.api.FileContentCryptor; import org.cryptomator.cryptolib.api.FileHeader; import org.cryptomator.cryptolib.api.FileHeaderCryptor; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -18,6 +19,7 @@ import static org.cryptomator.cryptofs.util.ByteBuffers.repeat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -37,9 +39,8 @@ public class ChunkSaverTest { private final CryptoFileSystemStats stats = mock(CryptoFileSystemStats.class); private final FileHeader header = mock(FileHeader.class); private final FileHeaderHolder headerHolder = mock(FileHeaderHolder.class); - private final ExceptionsDuringWrite exceptionsDuringWrite = mock(ExceptionsDuringWrite.class); private final BufferPool bufferPool = mock(BufferPool.class); - private final ChunkSaver inTest = new ChunkSaver(cryptor, chunkIO, headerHolder, exceptionsDuringWrite, stats, bufferPool); + private final ChunkSaver inTest = new ChunkSaver(cryptor, chunkIO, headerHolder, stats, bufferPool); @BeforeEach public void setup() throws IOException { @@ -53,13 +54,25 @@ public void setup() throws IOException { when(bufferPool.getCleartextBuffer()).thenAnswer(invocation -> ByteBuffer.allocate(CLEARTEXT_CHUNK_SIZE)); } + @Test + public void testSuccessfullWrittenChunkIsNonDirty() throws IOException { + long chunkIndex = 43L; + Supplier cleartext = () -> repeat(42).times(CLEARTEXT_CHUNK_SIZE).asByteBuffer(); + Chunk chunk = new Chunk(cleartext.get(), true, () -> {}); + doNothing().when(fileContentCryptor).encryptChunk(argThat(contains(cleartext.get())), Mockito.any(), eq(chunkIndex), eq(header)); + + inTest.save(chunkIndex, chunk); + + Assertions.assertFalse(chunk.isDirty()); + } + @Test public void testChunkInsideFileIsWritten() throws IOException { long chunkIndex = 43L; long expectedPosition = HEADER_SIZE + chunkIndex * CIPHERTEXT_CHUNK_SIZE; Supplier cleartext = () -> repeat(42).times(CLEARTEXT_CHUNK_SIZE).asByteBuffer(); Supplier ciphertext = () -> repeat(50).times(CIPHERTEXT_CHUNK_SIZE).asByteBuffer(); - Chunk chunk = new Chunk(cleartext.get(), true); + Chunk chunk = new Chunk(cleartext.get(), true, () -> {}); doAnswer(invocation -> { ByteBuffer ciphertextBuf = invocation.getArgument(1); ciphertextBuf.put(ciphertext.get()); @@ -79,7 +92,7 @@ public void testChunkContainingEndOfFileIsWrittenTillEndOfFile() throws IOExcept long expectedPosition = HEADER_SIZE + chunkIndex * CIPHERTEXT_CHUNK_SIZE; Supplier cleartext = () -> repeat(42).times(CLEARTEXT_CHUNK_SIZE - 10).asByteBuffer(); Supplier ciphertext = () -> repeat(50).times(CIPHERTEXT_CHUNK_SIZE - 10).asByteBuffer(); - Chunk chunk = new Chunk(cleartext.get(), true); + Chunk chunk = new Chunk(cleartext.get(), true, () -> {}); doAnswer(invocation -> { ByteBuffer ciphertextBuf = invocation.getArgument(1); ciphertextBuf.put(ciphertext.get()); @@ -96,7 +109,7 @@ public void testChunkContainingEndOfFileIsWrittenTillEndOfFile() throws IOExcept @Test public void testChunkThatWasNotWrittenIsNotWritten() throws IOException { Long chunkIndex = 43L; - Chunk chunk = new Chunk(ByteBuffer.allocate(CLEARTEXT_CHUNK_SIZE), false); + Chunk chunk = new Chunk(ByteBuffer.allocate(CLEARTEXT_CHUNK_SIZE), false, () -> {}); inTest.save(chunkIndex, chunk); @@ -105,25 +118,4 @@ public void testChunkThatWasNotWrittenIsNotWritten() throws IOException { verifyNoInteractions(bufferPool); } - @Test - public void testIOExceptionsDuringWriteAreAddedToExceptionsDuringWrite() throws IOException { - IOException ioException = new IOException(); - long chunkIndex = 43L; - long expectedPosition = HEADER_SIZE + chunkIndex * CIPHERTEXT_CHUNK_SIZE; - Supplier cleartext = () -> repeat(42).times(CLEARTEXT_CHUNK_SIZE).asByteBuffer(); - Supplier ciphertext = () -> repeat(50).times(CIPHERTEXT_CHUNK_SIZE).asByteBuffer(); - Chunk chunk = new Chunk(cleartext.get(), true); - doAnswer(invocation -> { - ByteBuffer ciphertextBuf = invocation.getArgument(1); - ciphertextBuf.put(ciphertext.get()); - return null; - }).when(fileContentCryptor).encryptChunk(argThat(contains(cleartext.get())), Mockito.any(), eq(chunkIndex), eq(header)); - when(chunkIO.write(argThat(contains(ciphertext.get())), eq(expectedPosition))).thenThrow(ioException); - - inTest.save(chunkIndex, chunk); - - verify(exceptionsDuringWrite).add(ioException); - verify(bufferPool).recycle(argThat(ByteBufferMatcher.hasCapacity(CIPHERTEXT_CHUNK_SIZE))); - } - } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/ChunkTest.java b/src/test/java/org/cryptomator/cryptofs/fh/ChunkTest.java index 01716e12..4f05ee7e 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/ChunkTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/ChunkTest.java @@ -15,28 +15,10 @@ public class ChunkTest { - @Test - public void testChunkDataWrappingBufferIsNotDirty() { - ByteBuffer buffer = repeat(3).times(200).asByteBuffer(); - - Chunk inTest = new Chunk(buffer, false); - - Assertions.assertFalse(inTest.isDirty()); - } - - @Test - public void testEmptyChunkDataIsNotDirty() { - ByteBuffer buffer = ByteBuffer.allocate(0); - - Chunk inTest = new Chunk(buffer, false); - - Assertions.assertFalse(inTest.isDirty()); - } - @Test // https://github.com/cryptomator/cryptofs/issues/85 public void testRaceConditionsDuringRead() { ByteBuffer src = StandardCharsets.US_ASCII.encode("abcdefg"); - Chunk inTest = new Chunk(src, false); + Chunk inTest = new Chunk(src, false, () -> {}); int attempts = 4000; int threads = 6;