diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 8aedebfe..9eeed1a8 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -414,9 +414,9 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti Files.createDirectories(ciphertextPath.getRawPath()); // suppresses FileAlreadyExists } - FileChannel ch = null; - try { - ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options, attrs); // might throw FileAlreadyExists + FileChannel ch = null; + try { + ch = openCryptoFiles.getOrCreate(cleartextFilePath, ciphertextFilePath).newFileChannel(options, attrs); // might throw FileAlreadyExists if (options.writable()) { ciphertextPath.persistLongFileName(); stats.incrementAccessesWritten(); @@ -618,7 +618,7 @@ private void moveSymlink(CryptoPath cleartextSource, CryptoPath cleartextTarget, // "the symbolic link itself, not the target of the link, is moved" CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); - try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath(), cleartextTarget)) { Files.move(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath(), options); if (ciphertextTarget.isShortened()) { ciphertextTarget.persistLongFileName(); @@ -634,7 +634,7 @@ private void moveFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, Co // we need to re-map the OpenCryptoFile entry. CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); - try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath(), cleartextTarget)) { checkUsage(cleartextSource, ciphertextSource); checkUsage(cleartextTarget, ciphertextTarget); if (ciphertextTarget.isShortened()) { @@ -742,4 +742,4 @@ void checkUsage(CryptoPath cleartextPath, CiphertextFilePath ciphertextPath) thr } } -} \ No newline at end of file +} diff --git a/src/main/java/org/cryptomator/cryptofs/Symlinks.java b/src/main/java/org/cryptomator/cryptofs/Symlinks.java index 4fd75445..d2ef3211 100644 --- a/src/main/java/org/cryptomator/cryptofs/Symlinks.java +++ b/src/main/java/org/cryptomator/cryptofs/Symlinks.java @@ -48,7 +48,7 @@ public void createSymbolicLink(CryptoPath cleartextPath, Path target, FileAttrib EffectiveOpenOptions openOptions = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW), readonlyFlag); ByteBuffer content = UTF_8.encode(target.toString()); Files.createDirectory(ciphertextFilePath.getRawPath()); - openCryptoFiles.writeCiphertextFile(ciphertextFilePath.getSymlinkFilePath(), openOptions, content); + openCryptoFiles.writeCiphertextFile(cleartextPath, ciphertextFilePath.getSymlinkFilePath(), openOptions, content); ciphertextFilePath.persistLongFileName(); } @@ -57,7 +57,7 @@ public CryptoPath readSymbolicLink(CryptoPath cleartextPath) throws IOException EffectiveOpenOptions openOptions = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.READ), readonlyFlag); assertIsSymlink(cleartextPath, ciphertextSymlinkFile); try { - ByteBuffer content = openCryptoFiles.readCiphertextFile(ciphertextSymlinkFile, openOptions, Constants.MAX_SYMLINK_LENGTH); + ByteBuffer content = openCryptoFiles.readCiphertextFile(cleartextPath, ciphertextSymlinkFile, openOptions, Constants.MAX_SYMLINK_LENGTH); return cleartextPath.getFileSystem().getPath(UTF_8.decode(content).toString()); } catch (BufferUnderflowException e) { throw new NotLinkException(cleartextPath.toString(), null, "Unreasonably large symlink file"); diff --git a/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java b/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java index dc5faacc..9e2a76bb 100644 --- a/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java +++ b/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java @@ -2,21 +2,29 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; +import jakarta.inject.Inject; import org.cryptomator.cryptofs.CryptoFileSystemStats; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; +import org.cryptomator.cryptofs.event.FileIsInUseEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptofs.fh.BufferPool; import org.cryptomator.cryptofs.fh.Chunk; import org.cryptomator.cryptofs.fh.ChunkCache; +import org.cryptomator.cryptofs.fh.CurrentOpenFileCleartextPath; import org.cryptomator.cryptofs.fh.CurrentOpenFilePath; import org.cryptomator.cryptofs.fh.ExceptionsDuringWrite; import org.cryptomator.cryptofs.fh.FileHeaderHolder; import org.cryptomator.cryptofs.fh.OpenFileModifiedDate; import org.cryptomator.cryptofs.fh.OpenFileSize; +import org.cryptomator.cryptofs.inuse.FileAlreadyInUseException; +import org.cryptomator.cryptofs.inuse.InUseManager; +import org.cryptomator.cryptofs.inuse.StubInUseManager; +import org.cryptomator.cryptofs.inuse.UseInfo; import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.MappedByteBuffer; @@ -55,10 +63,28 @@ public class CleartextFileChannel extends AbstractFileChannel { private final AtomicReference lastModified; private final ExceptionsDuringWrite exceptionsDuringWrite; private final Consumer closeListener; + private final Consumer eventConsumer; private final CryptoFileSystemStats stats; + private final InUseManager inUseManager; + private final AtomicReference currentCleartextPath; @Inject - public CleartextFileChannel(FileChannel ciphertextFileChannel, FileHeaderHolder fileHeaderHolder, ReadWriteLock readWriteLock, Cryptor cryptor, ChunkCache chunkCache, BufferPool bufferPool, EffectiveOpenOptions options, @OpenFileSize AtomicLong fileSize, @OpenFileModifiedDate AtomicReference lastModified, @CurrentOpenFilePath AtomicReference currentPath, ExceptionsDuringWrite exceptionsDuringWrite, Consumer closeListener, CryptoFileSystemStats stats) { + public CleartextFileChannel(FileChannel ciphertextFileChannel, // + FileHeaderHolder fileHeaderHolder, // + ReadWriteLock readWriteLock, // + Cryptor cryptor, // + ChunkCache chunkCache, // + BufferPool bufferPool, // + EffectiveOpenOptions options, // + @OpenFileSize AtomicLong fileSize, // + @OpenFileModifiedDate AtomicReference lastModified, // + @CurrentOpenFilePath AtomicReference currentPath, // + @CurrentOpenFileCleartextPath AtomicReference currentCleartextPath, // + ExceptionsDuringWrite exceptionsDuringWrite, // + Consumer closeListener, // + Consumer eventConsumer, // + CryptoFileSystemStats stats, // + InUseManager inUseManager) { super(readWriteLock); this.ciphertextFileChannel = ciphertextFileChannel; this.fileHeaderHolder = fileHeaderHolder; @@ -67,11 +93,14 @@ public CleartextFileChannel(FileChannel ciphertextFileChannel, FileHeaderHolder this.bufferPool = bufferPool; this.options = options; this.currentFilePath = currentPath; + this.currentCleartextPath = currentCleartextPath; this.fileSize = fileSize; this.lastModified = lastModified; this.exceptionsDuringWrite = exceptionsDuringWrite; this.closeListener = closeListener; + this.eventConsumer = eventConsumer; this.stats = stats; + this.inUseManager = inUseManager; if (options.append()) { position = fileSize.get(); } @@ -80,6 +109,23 @@ public CleartextFileChannel(FileChannel ciphertextFileChannel, FileHeaderHolder } } + @VisibleForTesting + CleartextFileChannel(FileChannel ciphertextFileChannel, // + FileHeaderHolder fileHeaderHolder, // + ReadWriteLock readWriteLock, // + Cryptor cryptor, // + ChunkCache chunkCache, // + BufferPool bufferPool, // + EffectiveOpenOptions options, // + AtomicLong fileSize, // + AtomicReference lastModified, // + AtomicReference currentPath, // + ExceptionsDuringWrite exceptionsDuringWrite, // + Consumer closeListener, // + CryptoFileSystemStats stats) { + this(ciphertextFileChannel, fileHeaderHolder, readWriteLock, cryptor, chunkCache, bufferPool, options, fileSize, lastModified, currentPath, new AtomicReference<>(null), exceptionsDuringWrite, closeListener, ignored -> {}, stats, new StubInUseManager()); + } + @Override public long size() throws IOException { assertOpen(); @@ -124,6 +170,17 @@ protected int readLocked(ByteBuffer dst, long position) throws IOException { @Override protected int writeLocked(ByteBuffer src, long position) throws IOException { + var path = currentFilePath.get(); + if (path != null && inUseManager.isInUseByOthers(path)) { + var useInfo = inUseManager.getUseInfo(path).orElse(new UseInfo("UNKNOWN", Instant.now())); + var cleartextPath = currentCleartextPath.get(); + if (cleartextPath != null) { + eventConsumer.accept(new FileIsInUseEvent(cleartextPath, path, useInfo.owner(), useInfo.lastUpdated(), () -> inUseManager.ignoreInUse(path))); + } else { + LOG.warn("Unable to emit FileIsInUseEvent: Cleartext path is null. Ciphertext path is {}.", path); + } + throw new FileAlreadyInUseException(path); + } long oldFileSize = fileSize.get(); long written; if (position > oldFileSize) { @@ -256,8 +313,8 @@ void persistLastModified() throws IOException { FileTime lastAccessTime = FileTime.from(Instant.now()); var p = currentFilePath.get(); if (p != null) { - p.getFileSystem().provider()// - .getFileAttributeView(p, BasicFileAttributeView.class) + p.getFileSystem().provider() // + .getFileAttributeView(p, BasicFileAttributeView.class) // .setTimes(lastModifiedTime, lastAccessTime, null); } @@ -328,7 +385,7 @@ long beginOfChunk(long cleartextPos) { protected void implCloseChannel() throws IOException { var closeActions = List.of(this::flush, // super::implCloseChannel, // - () -> closeListener.accept(ciphertextFileChannel), + () -> closeListener.accept(ciphertextFileChannel), // ciphertextFileChannel::close, // this::tryPersistLastModified); tryAll(closeActions.iterator()); diff --git a/src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFileCleartextPath.java b/src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFileCleartextPath.java new file mode 100644 index 00000000..921578d8 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/fh/CurrentOpenFileCleartextPath.java @@ -0,0 +1,14 @@ +package org.cryptomator.cryptofs.fh; + +import jakarta.inject.Qualifier; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Qualifier +@Documented +@Retention(RUNTIME) +public @interface CurrentOpenFileCleartextPath { +} diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 4b02690f..6d07ceba 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -1,6 +1,7 @@ package org.cryptomator.cryptofs.fh; import jakarta.inject.Inject; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.ch.CleartextFileChannel; import org.cryptomator.cryptofs.inuse.InUseManager; @@ -33,6 +34,7 @@ public class OpenCryptoFile implements Closeable { private final FileHeaderHolder headerHolder; private final ChunkIO chunkIO; private final AtomicReference currentFilePath; + private final AtomicReference currentCleartextPath; private final AtomicLong fileSize; private final OpenCryptoFileComponent component; @@ -42,15 +44,16 @@ public class OpenCryptoFile implements Closeable { @Inject public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHolder headerHolder, ChunkIO chunkIO, // @CurrentOpenFilePath AtomicReference currentFilePath, @OpenFileSize AtomicLong fileSize, // + @CurrentOpenFileCleartextPath AtomicReference currentCleartextPath, // @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component, // InUseManager inUseManager) { - this(listener, cryptor, headerHolder, chunkIO, currentFilePath, fileSize, lastModified, component, inUseManager, UseToken.CLOSED_TOKEN); + this(listener, cryptor, headerHolder, chunkIO, currentFilePath, fileSize, currentCleartextPath, lastModified, component, inUseManager, UseToken.CLOSED_TOKEN); } - //for testing OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHolder headerHolder, ChunkIO chunkIO, // @CurrentOpenFilePath AtomicReference currentFilePath, @OpenFileSize AtomicLong fileSize, // + @CurrentOpenFileCleartextPath AtomicReference currentCleartextPath, // @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component, // InUseManager inUseManager, UseToken token) { this.listener = listener; @@ -58,6 +61,7 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol this.headerHolder = headerHolder; this.chunkIO = chunkIO; this.currentFilePath = currentFilePath; + this.currentCleartextPath = currentCleartextPath; this.fileSize = fileSize; this.component = component; this.lastModified = lastModified; @@ -180,6 +184,10 @@ public Path getCurrentFilePath() { return currentFilePath.get(); } + public CryptoPath getCurrentCleartextPath() { + return currentCleartextPath.get(); + } + /** * Updates the current ciphertext file path, if it is not already set to null (i.e., the openCryptoFile is deleted) * @@ -190,10 +198,24 @@ public void updateCurrentFilePath(Path newFilePath) { if (newFilePath != null) { useToken.moveTo(newFilePath); } else { + currentCleartextPath.set(null); useToken.close(); //encrypted file will be deleted, hence we can stop checking usage } } + /** + * Updates the cleartext path if the file is not deleted (i.e., currentFilePath is not null). + * Null input is ignored. + * + * @param cleartextPath new cleartext path, or null to skip update + */ + public void updateCurrentCleartextPath(CryptoPath cleartextPath) { + if (cleartextPath == null) { + return; + } + currentCleartextPath.getAndUpdate(p -> currentFilePath.get() == null ? p : cleartextPath); + } + private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChannel) { if (ciphertextFileChannel != null) { chunkIO.unregisterChannel(ciphertextFileChannel); diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileModule.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileModule.java index cc71e002..6e6692c9 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileModule.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFileModule.java @@ -2,6 +2,7 @@ import dagger.Module; import dagger.Provides; +import org.cryptomator.cryptofs.CryptoPath; import java.io.IOException; import java.nio.file.Files; @@ -33,6 +34,13 @@ public AtomicReference provideCurrentPath(@OriginalOpenFilePath Path origi return new AtomicReference<>(originalPath); } + @Provides + @OpenFileScoped + @CurrentOpenFileCleartextPath + public AtomicReference provideCurrentCleartextPath() { + return new AtomicReference<>(null); + } + @Provides @OpenFileScoped @OpenFileModifiedDate diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java index 90d52d77..e3739e7d 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java @@ -10,6 +10,7 @@ import jakarta.inject.Inject; import org.cryptomator.cryptofs.CryptoFileSystemScoped; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; import java.io.Closeable; @@ -54,23 +55,30 @@ public Optional get(Path ciphertextPath) { * Opens a file to {@link OpenCryptoFile#newFileChannel(EffectiveOpenOptions, java.nio.file.attribute.FileAttribute[]) retrieve a FileChannel}. If this file is already opened, a shared instance is returned. * Getting the file channel should be the next invocation, since the {@link OpenFileScoped lifecycle} of the OpenFile strictly depends on the lifecycle of the channel. * + * @param cleartextPath Cleartext path of the file to open * @param ciphertextPath Path of the file to open * @return The opened file. * @see #get(Path) */ + public OpenCryptoFile getOrCreate(CryptoPath cleartextPath, Path ciphertextPath) { + OpenCryptoFile openFile = getOrCreate(ciphertextPath); + openFile.updateCurrentCleartextPath(cleartextPath); + return openFile; + } + public OpenCryptoFile getOrCreate(Path ciphertextPath) { Path normalizedPath = ciphertextPath.toAbsolutePath().normalize(); return openCryptoFiles.computeIfAbsent(normalizedPath, p -> openCryptoFileComponentFactory.create(p, openCryptoFiles::remove).openCryptoFile()); // computeIfAbsent is atomic, "create" is called at most once } - public void writeCiphertextFile(Path ciphertextPath, EffectiveOpenOptions openOptions, ByteBuffer contents) throws IOException { - try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { + public void writeCiphertextFile(CryptoPath cleartextPath, Path ciphertextPath, EffectiveOpenOptions openOptions, ByteBuffer contents) throws IOException { + try (OpenCryptoFile f = getOrCreate(cleartextPath, ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { ch.write(contents); } } - public ByteBuffer readCiphertextFile(Path ciphertextPath, EffectiveOpenOptions openOptions, int maxBufferSize) throws BufferUnderflowException, IOException { - try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { + public ByteBuffer readCiphertextFile(CryptoPath cleartextPath, Path ciphertextPath, EffectiveOpenOptions openOptions, int maxBufferSize) throws BufferUnderflowException, IOException { + try (OpenCryptoFile f = getOrCreate(cleartextPath, ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { if (ch.size() > maxBufferSize) { throw new BufferUnderflowException(); } @@ -101,11 +109,12 @@ public void delete(Path ciphertextPath) { * * @param src The ciphertext file path before the move * @param dst The ciphertext file path after the move + * @param cleartextDst cleartext file path after the move * @return Utility to update OpenCryptoFile references. * @throws FileAlreadyExistsException Thrown if the destination file is an existing file that is currently opened. */ - public TwoPhaseMove prepareMove(Path src, Path dst) throws FileAlreadyExistsException { - return new TwoPhaseMove(src, dst); + public TwoPhaseMove prepareMove(Path src, Path dst, CryptoPath cleartextDst) throws FileAlreadyExistsException { + return new TwoPhaseMove(src, dst, cleartextDst); } /** @@ -125,13 +134,15 @@ public class TwoPhaseMove implements AutoCloseable { private final Path src; private final Path dst; + private final CryptoPath cleartextDst; private final OpenCryptoFile openCryptoFile; private boolean committed; private boolean rolledBack; - private TwoPhaseMove(Path src, Path dst) throws FileAlreadyExistsException { + private TwoPhaseMove(Path src, Path dst, CryptoPath cleartextDst) throws FileAlreadyExistsException { this.src = Objects.requireNonNull(src); this.dst = Objects.requireNonNull(dst); + this.cleartextDst = cleartextDst; try { // ConcurrentHashMap.compute is atomic: this.openCryptoFile = openCryptoFiles.compute(dst, (k, v) -> { @@ -152,6 +163,7 @@ public void commit() { } if (openCryptoFile != null) { openCryptoFile.updateCurrentFilePath(dst); + openCryptoFile.updateCurrentCleartextPath(cleartextDst); } openCryptoFiles.remove(src, openCryptoFile); committed = true; diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 8754b59a..af158512 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -502,7 +502,7 @@ public void setup() throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextPath)).thenReturn(CiphertextFileType.FILE); when(cryptoPathMapper.getCiphertextFilePath(cleartextPath)).thenReturn(ciphertextPath); when(ciphertextPath.getFilePath()).thenReturn(ciphertextFilePath); - when(openCryptoFiles.getOrCreate(ciphertextFilePath)).thenReturn(openCryptoFile); + when(openCryptoFiles.getOrCreate(cleartextPath, ciphertextFilePath)).thenReturn(openCryptoFile); when(ciphertextFilePath.getName(3)).thenReturn(mock(CryptoPath.class, "path.c9r")); when(openCryptoFile.newFileChannel(any(), any(FileAttribute[].class))).thenReturn(fileChannel); } @@ -849,7 +849,7 @@ public void moveSymlink() throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextSource)).thenReturn(CiphertextFileType.SYMLINK); when(cryptoPathMapper.getCiphertextFileType(cleartextDestination)).thenThrow(NoSuchFileException.class); TwoPhaseMove openFileMove = Mockito.mock(TwoPhaseMove.class); - Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, ciphertextDestinationFile)).thenReturn(openFileMove); + Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, ciphertextDestinationFile, cleartextDestination)).thenReturn(openFileMove); CopyOption option1 = mock(CopyOption.class); CopyOption option2 = mock(CopyOption.class); @@ -866,7 +866,7 @@ public void moveFile() throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextSource)).thenReturn(CiphertextFileType.FILE); when(cryptoPathMapper.getCiphertextFileType(cleartextDestination)).thenThrow(NoSuchFileException.class); TwoPhaseMove openFileMove = Mockito.mock(TwoPhaseMove.class); - Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, ciphertextDestinationFile)).thenReturn(openFileMove); + Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, ciphertextDestinationFile, cleartextDestination)).thenReturn(openFileMove); CopyOption option1 = mock(CopyOption.class); CopyOption option2 = mock(CopyOption.class); @@ -904,7 +904,7 @@ private void moveFileWithXInUse(CryptoFileSystemImpl inTestSpy) throws IOExcepti when(cryptoPathMapper.getCiphertextFileType(cleartextSource)).thenReturn(CiphertextFileType.FILE); when(cryptoPathMapper.getCiphertextFileType(cleartextDestination)).thenThrow(NoSuchFileException.class); TwoPhaseMove openFileMove = Mockito.mock(TwoPhaseMove.class); - Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, ciphertextDestinationFile)).thenReturn(openFileMove); + Mockito.when(openCryptoFiles.prepareMove(ciphertextSourceFile, ciphertextDestinationFile, cleartextDestination)).thenReturn(openFileMove); CopyOption option1 = mock(CopyOption.class); CopyOption option2 = mock(CopyOption.class); diff --git a/src/test/java/org/cryptomator/cryptofs/SymlinksTest.java b/src/test/java/org/cryptomator/cryptofs/SymlinksTest.java index 5169dc64..fe35d535 100644 --- a/src/test/java/org/cryptomator/cryptofs/SymlinksTest.java +++ b/src/test/java/org/cryptomator/cryptofs/SymlinksTest.java @@ -71,7 +71,7 @@ public void testCreateSymbolicLink() throws IOException { ArgumentCaptor bytesWritten = ArgumentCaptor.forClass(ByteBuffer.class); Mockito.verify(underlyingFsProvider).createDirectory(Mockito.eq(ciphertextPath), Mockito.any(FileAttribute[].class)); - Mockito.verify(openCryptoFiles).writeCiphertextFile(Mockito.eq(symlinkFilePath), Mockito.any(), bytesWritten.capture()); + Mockito.verify(openCryptoFiles).writeCiphertextFile(Mockito.eq(cleartextPath), Mockito.eq(symlinkFilePath), Mockito.any(), bytesWritten.capture()); Assertions.assertEquals("/symlink/target/path", StandardCharsets.UTF_8.decode(bytesWritten.getValue()).toString()); } @@ -85,7 +85,7 @@ public void testReadSymbolicLink() throws IOException { CryptoPath resolvedTargetPath = Mockito.mock(CryptoPath.class, "resolvedTargetPath"); Path ciphertextPath = mockExistingSymlink(cleartextPath); Path symlinkFilePath = ciphertextPath.resolve("symlink.c9r"); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(symlinkFilePath), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode(targetPath)); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath), Mockito.eq(symlinkFilePath), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode(targetPath)); Mockito.when(cleartextFs.getPath(targetPath)).thenReturn(resolvedTargetPath); @@ -120,8 +120,8 @@ public void testResolveRecursively() throws IOException { Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath1)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath2)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath3)).thenReturn(CiphertextFileType.FILE); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath2), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file3")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath1), Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath2), Mockito.eq(ciphertextSymlinkPath2), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file3")); Mockito.when(cleartextFs.getPath("file2")).thenReturn(cleartextPath2); Mockito.when(cleartextFs.getPath("file3")).thenReturn(cleartextPath3); @@ -141,7 +141,7 @@ public void testResolveRecursivelyWithNonExistingTarget() throws IOException { Mockito.when(cleartextPath2.getFileSystem()).thenReturn(cleartextFs); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath1)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath2)).thenThrow(new NoSuchFileException("cleartextPath2")); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath1), Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); Mockito.when(cleartextFs.getPath("file2")).thenReturn(cleartextPath2); CryptoPath resolved = inTest.resolveRecursively(cleartextPath1); @@ -167,9 +167,9 @@ public void testResolveRecursivelyWithLoop() throws IOException { Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath1)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath2)).thenReturn(CiphertextFileType.SYMLINK); Mockito.when(cryptoPathMapper.getCiphertextFileType(cleartextPath3)).thenReturn(CiphertextFileType.SYMLINK); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath2), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file3")); - Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(ciphertextSymlinkPath3), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file1")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath1), Mockito.eq(ciphertextSymlinkPath1), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file2")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath2), Mockito.eq(ciphertextSymlinkPath2), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file3")); + Mockito.when(openCryptoFiles.readCiphertextFile(Mockito.eq(cleartextPath3), Mockito.eq(ciphertextSymlinkPath3), Mockito.any(), Mockito.anyInt())).thenReturn(StandardCharsets.UTF_8.encode("file1")); Mockito.when(cleartextFs.getPath("file2")).thenReturn(cleartextPath2); Mockito.when(cleartextFs.getPath("file3")).thenReturn(cleartextPath3); Mockito.when(cleartextFs.getPath("file1")).thenReturn(cleartextPath1); diff --git a/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java b/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java index e203b16b..b463de57 100644 --- a/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java +++ b/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java @@ -1,12 +1,17 @@ package org.cryptomator.cryptofs.ch; import org.cryptomator.cryptofs.CryptoFileSystemStats; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptofs.fh.BufferPool; import org.cryptomator.cryptofs.fh.Chunk; import org.cryptomator.cryptofs.fh.ChunkCache; import org.cryptomator.cryptofs.fh.ExceptionsDuringWrite; import org.cryptomator.cryptofs.fh.FileHeaderHolder; +import org.cryptomator.cryptofs.inuse.FileAlreadyInUseException; +import org.cryptomator.cryptofs.inuse.InUseManager; +import org.cryptomator.cryptofs.inuse.UseInfo; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileContentCryptor; import org.cryptomator.cryptolib.api.FileHeaderCryptor; @@ -78,7 +83,11 @@ public class CleartextFileChannelTest { private BasicFileAttributeView attributeView = mock(BasicFileAttributeView.class); private ExceptionsDuringWrite exceptionsDuringWrite = mock(ExceptionsDuringWrite.class); private Consumer closeListener = mock(Consumer.class); + private Consumer eventConsumer = mock(Consumer.class); private CryptoFileSystemStats stats = mock(CryptoFileSystemStats.class); + private InUseManager inUseManager = mock(InUseManager.class); + private CryptoPath cleartextPath = mock(CryptoPath.class, "/clear/path"); + private AtomicReference currentCleartextPath = new AtomicReference<>(cleartextPath); private CleartextFileChannel inTest; @@ -576,6 +585,19 @@ public void testDontRewriteHeader() throws IOException { Mockito.verify(ciphertextFileChannel, Mockito.never()).write(Mockito.any(), Mockito.eq(0l)); } + @Test + @DisplayName("write fails and emits event if file is now in-use by others") + public void testWriteFailsOnExternalInUse() throws IOException { + when(options.writable()).thenReturn(true); + when(inUseManager.isInUseByOthers(filePath)).thenReturn(true); + when(inUseManager.getUseInfo(filePath)).thenReturn(java.util.Optional.of(new UseInfo("alice", Instant.now()))); + var channel = new CleartextFileChannel(ciphertextFileChannel, headerHolder, readWriteLock, cryptor, chunkCache, bufferPool, options, fileSize, lastModified, currentFilePath, currentCleartextPath, + exceptionsDuringWrite, closeListener, eventConsumer, stats, inUseManager); + + Assertions.assertThrows(FileAlreadyInUseException.class, () -> channel.write(ByteBuffer.allocate(1), 0)); + verify(eventConsumer).accept(any()); + } + } @Nested diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index b081b926..6de33291 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -2,6 +2,7 @@ import com.google.common.jimfs.Configuration; import com.google.common.jimfs.Jimfs; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.ReadonlyFlag; import org.cryptomator.cryptofs.ch.ChannelComponent; @@ -53,6 +54,7 @@ public class OpenCryptoFileTest { private static FileSystem FS; private static AtomicReference CURRENT_FILE_PATH; + private static AtomicReference CURRENT_CLEARTEXT_FILE_PATH; private ReadonlyFlag readonlyFlag = mock(ReadonlyFlag.class); private FileCloseListener closeListener = mock(FileCloseListener.class); private Cryptor cryptor = mock(Cryptor.class); @@ -70,6 +72,7 @@ public class OpenCryptoFileTest { @BeforeAll public static void setup() { FS = Jimfs.newFileSystem("OpenCryptoFileTest", Configuration.unix().toBuilder().setAttributeViews("basic", "posix").build()); + CURRENT_CLEARTEXT_FILE_PATH = new AtomicReference<>(Mockito.mock(CryptoPath.class, "/clear/text/path")); CURRENT_FILE_PATH = new AtomicReference<>(FS.getPath("currentFile")); } @@ -89,7 +92,7 @@ OpenCryptoFile getTestInstance(String filename) { throw new RuntimeException("Path " + p + "already exists."); } CURRENT_FILE_PATH.set(p); - return new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseManager, useToken); + return new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, CURRENT_CLEARTEXT_FILE_PATH, lastModified, openCryptoFileComponent, inUseManager, useToken); } @Test @@ -250,7 +253,7 @@ public void testUpdateCurrentPath() { var currentPath = mock(Path.class, "current Path"); var newPath = mock(Path.class, "new Path"); var currentPathWrapper = new AtomicReference<>(currentPath); - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, currentPathWrapper, fileSize, lastModified, openCryptoFileComponent, inUseManager, useToken); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, currentPathWrapper, fileSize, CURRENT_CLEARTEXT_FILE_PATH, lastModified, openCryptoFileComponent, inUseManager, useToken); doNothing().when(useToken).moveTo(newPath); openCryptoFile.updateCurrentFilePath(newPath); @@ -262,7 +265,7 @@ public void testUpdateCurrentPath() { public void testUpdateCurrentPathWithNull() { var currentPath = mock(Path.class, "current Path"); var currentPathWrapper = new AtomicReference<>(currentPath); - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, currentPathWrapper, fileSize, lastModified, openCryptoFileComponent, inUseManager, useToken); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, currentPathWrapper, fileSize, CURRENT_CLEARTEXT_FILE_PATH, lastModified, openCryptoFileComponent, inUseManager, useToken); doNothing().when(useToken).close(); openCryptoFile.updateCurrentFilePath(null); @@ -276,7 +279,7 @@ public class InitFilHeaderTests { EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); FileChannel cipherFileChannel = Mockito.mock(FileChannel.class, "cipherFilechannel"); - OpenCryptoFile inTest = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseManager); + OpenCryptoFile inTest = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, CURRENT_CLEARTEXT_FILE_PATH, lastModified, openCryptoFileComponent, inUseManager); @Test @DisplayName("Skip file header init, if the file header already exists in memory") @@ -360,7 +363,7 @@ public class FileChannelFactoryTest { public void setup() throws IOException { FS = Jimfs.newFileSystem("OpenCryptoFileTest.FileChannelFactoryTest", Configuration.unix().toBuilder().setAttributeViews("basic", "posix").build()); CURRENT_FILE_PATH = new AtomicReference<>(FS.getPath("currentFile")); - openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, realFileSize, lastModified, openCryptoFileComponent, inUseManager); + openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, realFileSize, CURRENT_CLEARTEXT_FILE_PATH, lastModified, openCryptoFileComponent, inUseManager); cleartextFileChannel = mock(CleartextFileChannel.class); listener = new AtomicReference<>(); ciphertextChannel = new AtomicReference<>(); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java index 9024d62f..a220c8d5 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java @@ -1,5 +1,6 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -62,7 +63,7 @@ public void testWriteCiphertextFile() throws IOException { EffectiveOpenOptions openOptions = Mockito.mock(EffectiveOpenOptions.class); ByteBuffer contents = StandardCharsets.UTF_8.encode("hello world"); - inTest.writeCiphertextFile(path, openOptions, contents); + inTest.writeCiphertextFile(Mockito.mock(CryptoPath.class), path, openOptions, contents); Mockito.verify(ciphertextFileChannel).write(contents); } @@ -79,7 +80,7 @@ public void testReadCiphertextFile() throws IOException { return contents.length; }); - ByteBuffer bytesRead = inTest.readCiphertextFile(path, openOptions, 1337); + ByteBuffer bytesRead = inTest.readCiphertextFile(Mockito.mock(CryptoPath.class), path, openOptions, 1337); Assertions.assertEquals("hello world", StandardCharsets.UTF_8.decode(bytesRead).toString()); } @@ -91,7 +92,7 @@ public void testTwoPhaseMoveFailsWhenTargetIsOpened() throws IOException { inTest.getOrCreate(dst); Assertions.assertThrows(FileAlreadyExistsException.class, () -> { - inTest.prepareMove(src, dst); + inTest.prepareMove(src, dst, Mockito.mock(CryptoPath.class)); }); } @@ -103,7 +104,7 @@ public void testTwoPhaseMoveDoesntChangeAnythingWhenRolledBack() throws IOExcept Assertions.assertTrue(inTest.get(src).isPresent()); Assertions.assertFalse(inTest.get(dst).isPresent()); - try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, dst)) { + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, dst, Mockito.mock(CryptoPath.class))) { twoPhaseMove.rollback(); } Assertions.assertTrue(inTest.get(src).isPresent()); @@ -119,7 +120,7 @@ public void testTwoPhaseMoveChangesReferencesWhenCommitted() throws IOException Assertions.assertTrue(inTest.get(src).isPresent()); Assertions.assertFalse(inTest.get(dst).isPresent()); OpenCryptoFile srcFile = inTest.get(src).get(); - try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, dst)) { + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, dst, Mockito.mock(CryptoPath.class))) { twoPhaseMove.commit(); } Assertions.assertFalse(inTest.get(src).isPresent()); @@ -128,6 +129,20 @@ public void testTwoPhaseMoveChangesReferencesWhenCommitted() throws IOException Assertions.assertSame(srcFile, dstFile); } + @Test + public void testTwoPhaseMoveUpdatesCleartextPathWhenCommitted() throws IOException { + Path src = Paths.get("/src").toAbsolutePath(); + Path dst = Paths.get("/dst").toAbsolutePath(); + CryptoPath cleartextDst = mock(CryptoPath.class); + OpenCryptoFile srcFile = inTest.getOrCreate(src); + + try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = inTest.prepareMove(src, dst, cleartextDst)) { + twoPhaseMove.commit(); + } + + Mockito.verify(srcFile).updateCurrentCleartextPath(cleartextDst); + } + @Test public void testCloseClosesRemainingOpenFiles() { Path path1 = Mockito.mock(Path.class, "/file1");