From f45b61e1a58d85da837766d9c89831ffc26a42ef Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 30 Jul 2025 12:15:52 +0200 Subject: [PATCH 01/95] first draft when to open lock files --- .../cryptofs/fh/OpenCryptoFile.java | 36 ++++++++++-- .../cryptofs/fh/OpenCryptoFileTest.java | 57 ++++++++++++++++--- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 81e6b886..7a890ff7 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -1,16 +1,19 @@ package org.cryptomator.cryptofs.fh; +import jakarta.inject.Inject; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.ch.CleartextFileChannel; import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; import java.io.Closeable; import java.io.IOException; import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.nio.file.attribute.FileAttribute; import java.nio.file.attribute.FileTime; import java.time.Instant; @@ -34,6 +37,7 @@ public class OpenCryptoFile implements Closeable { private final OpenCryptoFileComponent component; private final AtomicInteger openChannelsCount = new AtomicInteger(0); + private volatile SeekableByteChannel inUseFileChannel; @Inject public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHolder headerHolder, ChunkIO chunkIO, // @@ -64,7 +68,11 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil FileChannel ciphertextFileChannel = null; CleartextFileChannel cleartextFileChannel = null; - openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number + var openChannels = openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number + // in-use section + if (openChannels == 1) { + createInUseFile(path); + } try { ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); initFileHeader(options, ciphertextFileChannel); @@ -81,12 +89,31 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil closeQuietly(ciphertextFileChannel); } } - assert cleartextFileChannel != null; // otherwise there would have been an exception chunkIO.registerChannel(ciphertextFileChannel, options.writable()); return cleartextFileChannel; } + void createInUseFile(Path ciphertextPath) throws IOException { + var inUseFilePath = getInUseFilePath(ciphertextPath); + this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + } + + void deleteInUseFile() { + if (inUseFileChannel != null) { + try { + inUseFileChannel.close(); //TODO: DELETE_ON_CLOSE should clean up. Do we need a dedicated cleanup routine? + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private Path getInUseFilePath(Path p) { + var ciphertextName = p.getFileName().toString(); + return p.resolveSibling(ciphertextName.substring(0, ciphertextName.length() - 3) + "c9l"); + } + //visible for testing void initFileHeader(EffectiveOpenOptions options, FileChannel ciphertextFileChannel) throws IOException { try { @@ -183,8 +210,9 @@ private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChann @Override public void close() { var p = currentFilePath.get(); - if(p != null) { + if (p != null) { listener.close(p, this); + deleteInUseFile(); } } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index e50b7d57..1515041d 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -36,8 +36,10 @@ import java.util.function.Consumer; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -69,30 +71,64 @@ public static void tearDown() throws IOException { } @Test - public void testCloseTriggersCloseListener() { - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent); + @DisplayName("on close(), trigger closeListener and delete lockFile") + public void testClose() { + OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); + doNothing().when(openCryptoFile).deleteInUseFile(); openCryptoFile.close(); verify(closeListener).close(CURRENT_FILE_PATH.get(), openCryptoFile); + verify(openCryptoFile).deleteInUseFile(); } // tests https://github.com/cryptomator/cryptofs/issues/51 @Test - public void testCloseImmediatelyIfOpeningFirstChannelFails() { + @DisplayName("if the first file channel fails to open, call OpenCryptoFile::close") + public void testFailedFirstFileChannelImmediatelyCallsClose() { UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent); + OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { openCryptoFile.newFileChannel(options); }); Assertions.assertSame(expectedException, exception); - verify(closeListener).close(CURRENT_FILE_PATH.get(), openCryptoFile); + verify(openCryptoFile).close(); + } + + @Test + @DisplayName("if the second file channel fails to open, do nothing") + public void testFailedSecondFileChannelDoesNothing() throws IOException { + CURRENT_FILE_PATH.set(FS.getPath("secondDoesNotFail")); + UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); + EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); + var cleartextChannel = mock(CleartextFileChannel.class); + Mockito.when(headerHolder.get()).thenReturn(Mockito.mock(FileHeader.class)); + Mockito.when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(42); + Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); + Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); + Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); + + EffectiveOpenOptions failingOptions = Mockito.mock(EffectiveOpenOptions.class); + Mockito.when(failingOptions.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); + OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); + doNothing().when(openCryptoFile).createInUseFile(any()); + doNothing().when(openCryptoFile).deleteInUseFile(); + + try (var channel = openCryptoFile.newFileChannel(options)) { + UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { + openCryptoFile.newFileChannel(failingOptions); + }); + Assertions.assertSame(expectedException, exception); + verify(openCryptoFile, never()).close(); + } } @Test @DisplayName("Opening a file channel with TRUNCATE_EXISTING calls truncate(0) on the cleartextChannel") public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOException { + CURRENT_FILE_PATH.set(FS.getPath("truncate")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING), readonlyFlag); var cleartextChannel = mock(CleartextFileChannel.class); Mockito.when(headerHolder.get()).thenReturn(Mockito.mock(FileHeader.class)); @@ -101,7 +137,9 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent); + OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); + doNothing().when(openCryptoFile).createInUseFile(any()); + doNothing().when(openCryptoFile).deleteInUseFile(); openCryptoFile.newFileChannel(options); verify(cleartextChannel).truncate(0L); @@ -197,7 +235,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); + openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, realFileSize, lastModified, openCryptoFileComponent); cleartextFileChannel = mock(CleartextFileChannel.class); listener = new AtomicReference<>(); ciphertextChannel = new AtomicReference<>(); @@ -224,9 +262,12 @@ public void testGetSizeBeforeCreatingFileChannel() { public void createFileChannel() throws IOException { var attrs = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); - FileChannel ch = openCryptoFile.newFileChannel(options, attrs); + var openCryptoFileSpy = spy(openCryptoFile); + doNothing().when(openCryptoFileSpy).createInUseFile(any()); + FileChannel ch = openCryptoFileSpy.newFileChannel(options, attrs); Assertions.assertSame(cleartextFileChannel, ch); verify(chunkIO).registerChannel(ciphertextChannel.get(), true); + verify(openCryptoFileSpy).createInUseFile(CURRENT_FILE_PATH.get()); } @Test From 2248ea8b630deb0f8bd4f953453c2842907ce149 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 30 Jul 2025 21:19:42 +0200 Subject: [PATCH 02/95] extend logic to read inUseFile on failure --- .../cryptofs/fh/FileIsInUseException.java | 11 +++++ .../cryptofs/fh/OpenCryptoFile.java | 40 ++++++++++++++++--- .../cryptofs/fh/OpenCryptoFileTest.java | 25 ++++++++++-- 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/fh/FileIsInUseException.java diff --git a/src/main/java/org/cryptomator/cryptofs/fh/FileIsInUseException.java b/src/main/java/org/cryptomator/cryptofs/fh/FileIsInUseException.java new file mode 100644 index 00000000..4f588f6b --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/fh/FileIsInUseException.java @@ -0,0 +1,11 @@ +package org.cryptomator.cryptofs.fh; + +import java.nio.file.FileSystemException; +import java.nio.file.Path; + +public class FileIsInUseException extends FileSystemException { + + public FileIsInUseException(Path path) { + super(path.toString()); + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 7a890ff7..4b23c471 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.channels.SeekableByteChannel; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; @@ -69,11 +70,11 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil CleartextFileChannel cleartextFileChannel = null; var openChannels = openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number - // in-use section - if (openChannels == 1) { - createInUseFile(path); - } try { + // in-use section + if (openChannels == 1 && isFileInUse(path)) { //add ignore mechanic + throw new FileIsInUseException(path); + } ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); initFileHeader(options, ciphertextFileChannel); initFileSize(ciphertextFileChannel); @@ -94,11 +95,40 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil return cleartextFileChannel; } - void createInUseFile(Path ciphertextPath) throws IOException { + boolean isFileInUse(Path ciphertextPath) { var inUseFilePath = getInUseFilePath(ciphertextPath); + try { + createInUseFile(inUseFilePath); + return true; + } catch (FileAlreadyExistsException e) { + Object content = readInUseFile(inUseFilePath); + //check if file belongs to us + //if yes, do stuff and return false + //otherwise notify user and return true + } catch (IOException e) { + LOG.warn("Failed to create in-use file for {}.", ciphertextPath, e); + } + return false; + } + + void createInUseFile(Path inUseFilePath) throws IOException { this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); } + Object readInUseFile(Path inUseFilePath) { + try { + if (Files.size(inUseFilePath) > 4_000) { + throw new IOException("in-use-file exceeds max size of 4000KB"); + } + var bytes = Files.readAllBytes(inUseFilePath); + //TODO: convert to JSON an extract info + return new Object(); + } catch (IOException e) { + LOG.warn("Unable to read in-use-file", e); + return new Object(); //default object + } + } + void deleteInUseFile() { if (inUseFileChannel != null) { try { diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index 1515041d..c76d90c3 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -37,11 +37,13 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class OpenCryptoFileTest { @@ -88,6 +90,7 @@ public void testFailedFirstFileChannelImmediatelyCallsClose() { EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); + when(openCryptoFile.isFileInUse(CURRENT_FILE_PATH.get())).thenReturn(false); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { openCryptoFile.newFileChannel(options); @@ -96,6 +99,20 @@ public void testFailedFirstFileChannelImmediatelyCallsClose() { verify(openCryptoFile).close(); } + @Test + @DisplayName("if the file is in use, throw exception") + public void testInUseFileThrowsException() { + EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); + OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); + when(openCryptoFile.isFileInUse(CURRENT_FILE_PATH.get())).thenReturn(true); + + Assertions.assertThrows(FileIsInUseException.class, () -> { + openCryptoFile.newFileChannel(options); + }); + } + + //TODO: test, if in-use-file-exists, but it is ignored with flag + @Test @DisplayName("if the second file channel fails to open, do nothing") public void testFailedSecondFileChannelDoesNothing() throws IOException { @@ -113,7 +130,7 @@ public void testFailedSecondFileChannelDoesNothing() throws IOException { EffectiveOpenOptions failingOptions = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(failingOptions.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); - doNothing().when(openCryptoFile).createInUseFile(any()); + when(openCryptoFile.isFileInUse(CURRENT_FILE_PATH.get())).thenReturn(false); doNothing().when(openCryptoFile).deleteInUseFile(); try (var channel = openCryptoFile.newFileChannel(options)) { @@ -138,7 +155,7 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); - doNothing().when(openCryptoFile).createInUseFile(any()); + when(openCryptoFile.isFileInUse(CURRENT_FILE_PATH.get())).thenReturn(false); doNothing().when(openCryptoFile).deleteInUseFile(); openCryptoFile.newFileChannel(options); @@ -263,11 +280,11 @@ public void createFileChannel() throws IOException { var attrs = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); var openCryptoFileSpy = spy(openCryptoFile); - doNothing().when(openCryptoFileSpy).createInUseFile(any()); + doReturn(false).when(openCryptoFileSpy).isFileInUse(any()); FileChannel ch = openCryptoFileSpy.newFileChannel(options, attrs); Assertions.assertSame(cleartextFileChannel, ch); verify(chunkIO).registerChannel(ciphertextChannel.get(), true); - verify(openCryptoFileSpy).createInUseFile(CURRENT_FILE_PATH.get()); + verify(openCryptoFileSpy).isFileInUse(CURRENT_FILE_PATH.get()); } @Test From 751dcc7c9be8749fc6cbf7d8797320ee44b70b29 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 31 Jul 2025 09:51:41 +0200 Subject: [PATCH 03/95] refactor inUse check: * first read in-use-file * on failure create/own it --- .../cryptofs/common/FileTooBigException.java | 12 ++++++ .../cryptomator/cryptofs/common/FileUtil.java | 19 +++++++++ .../cryptofs/fh/OpenCryptoFile.java | 40 ++++++++++++------- 3 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/common/FileTooBigException.java create mode 100644 src/main/java/org/cryptomator/cryptofs/common/FileUtil.java diff --git a/src/main/java/org/cryptomator/cryptofs/common/FileTooBigException.java b/src/main/java/org/cryptomator/cryptofs/common/FileTooBigException.java new file mode 100644 index 00000000..15f074fc --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/common/FileTooBigException.java @@ -0,0 +1,12 @@ +package org.cryptomator.cryptofs.common; + +import java.io.IOException; +import java.nio.file.Path; + +public class FileTooBigException extends IOException { + + public FileTooBigException(Path file, long currentSize, int maxSize) { + super("File %s has size %d, exceeding maximum allowed size of %d.".formatted(file, currentSize, maxSize)); + } + +} diff --git a/src/main/java/org/cryptomator/cryptofs/common/FileUtil.java b/src/main/java/org/cryptomator/cryptofs/common/FileUtil.java new file mode 100644 index 00000000..93ccf74a --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/common/FileUtil.java @@ -0,0 +1,19 @@ +package org.cryptomator.cryptofs.common; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class FileUtil { + + private FileUtil() {}; + + public static byte[] readAllBytesSizeRestricted(Path target, int maxSize) throws IOException { + long size = Files.size(target); + if (size > maxSize) { + throw new FileTooBigException(target, size, maxSize); + } + return Files.readAllBytes(target); + } + +} diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 4b23c471..116299eb 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -3,6 +3,8 @@ import jakarta.inject.Inject; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.ch.CleartextFileChannel; +import org.cryptomator.cryptofs.common.FileTooBigException; +import org.cryptomator.cryptofs.common.FileUtil; import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,6 +15,7 @@ import java.nio.channels.SeekableByteChannel; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.FileAttribute; @@ -98,34 +101,41 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil boolean isFileInUse(Path ciphertextPath) { var inUseFilePath = getInUseFilePath(ciphertextPath); try { - createInUseFile(inUseFilePath); - return true; - } catch (FileAlreadyExistsException e) { Object content = readInUseFile(inUseFilePath); //check if file belongs to us //if yes, do stuff and return false //otherwise notify user and return true + return true; + } catch (NoSuchFileException e) { + createInUseFile(inUseFilePath); + } catch (FileTooBigException e) { + LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); + ownInUseFile(inUseFilePath); } catch (IOException e) { - LOG.warn("Failed to create in-use file for {}.", ciphertextPath, e); + LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); } return false; } - void createInUseFile(Path inUseFilePath) throws IOException { - this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + Object readInUseFile(Path inUseFilePath) throws IOException { + var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); + //TODO: convert to JSON an extract info + return new Object(); } - Object readInUseFile(Path inUseFilePath) { + void createInUseFile(Path inUseFilePath) { try { - if (Files.size(inUseFilePath) > 4_000) { - throw new IOException("in-use-file exceeds max size of 4000KB"); - } - var bytes = Files.readAllBytes(inUseFilePath); - //TODO: convert to JSON an extract info - return new Object(); + this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + } catch (IOException e) { + LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + } + } + + void ownInUseFile(Path inUseFilePath) { + try { + this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); } catch (IOException e) { - LOG.warn("Unable to read in-use-file", e); - return new Object(); //default object + LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); } } From 3dd536b0e20b63da8666c5ca9e6018fa3f97dc1e Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 31 Jul 2025 12:44:27 +0200 Subject: [PATCH 04/95] refactor to own class --- .../cryptomator/cryptofs/fh/InUseFile.java | 100 ++++++++++++++++++ .../cryptofs/fh/OpenCryptoFile.java | 73 ++----------- .../cryptofs/fh/OpenCryptoFileTest.java | 37 +++---- 3 files changed, 122 insertions(+), 88 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java new file mode 100644 index 00000000..0e1285eb --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -0,0 +1,100 @@ +package org.cryptomator.cryptofs.fh; + +import jakarta.inject.Inject; +import org.cryptomator.cryptofs.common.FileTooBigException; +import org.cryptomator.cryptofs.common.FileUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.atomic.AtomicReference; + +@OpenFileScoped +public class InUseFile implements Closeable { + + private static final Logger LOG = LoggerFactory.getLogger(InUseFile.class); + + private final AtomicReference currentFilePath; + private SeekableByteChannel inUseFileChannel; + + @Inject + public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath) { + //TODO: add notifier + this.currentFilePath = currentFilePath; + } + + boolean checkOrOwn() { + var ciphertextPath = currentFilePath.get(); + var inUseFilePath = getInUseFilePath(ciphertextPath); + try { + Object content = readInUseFile(inUseFilePath); + //check if file belongs to us + //if yes, do stuff and return false + //otherwise notify user and return true + return true; + } catch (NoSuchFileException e) { + LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); + createInUseFile(inUseFilePath); + } catch (FileTooBigException e) { + LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); + ownInUseFile(inUseFilePath); + } catch (IOException e) { + LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); + } + return false; + } + + Object readInUseFile(Path inUseFilePath) throws IOException { + var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); + //TODO: convert to JSON an extract info + return new Object(); + } + + void createInUseFile(Path inUseFilePath) { + try { + this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + writeInUseInfo(); + } catch (IOException e) { + LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + } + } + + void ownInUseFile(Path inUseFilePath) { + try { + this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); + writeInUseInfo(); + } catch (IOException e) { + LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + } + } + + void writeInUseInfo() throws IOException { + this.inUseFileChannel.write(ByteBuffer.wrap("Test String".getBytes(StandardCharsets.UTF_8))); + } + + + @Override + public void close() { + if (inUseFileChannel != null) { + try { + inUseFileChannel.close(); //TODO: DELETE_ON_CLOSE should clean up. Do we need a dedicated cleanup routine? + } catch (IOException e) { + LOG.error("Unable to delete in-use-file. Must be cleaned manually."); + //throw new RuntimeException(e); + } + } + } + + private Path getInUseFilePath(Path p) { + var ciphertextName = p.getFileName().toString(); + return p.resolveSibling(ciphertextName.substring(0, ciphertextName.length() - 3) + "c9l"); + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 116299eb..dd5d845c 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -3,8 +3,6 @@ import jakarta.inject.Inject; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.ch.CleartextFileChannel; -import org.cryptomator.cryptofs.common.FileTooBigException; -import org.cryptomator.cryptofs.common.FileUtil; import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,12 +10,7 @@ import java.io.Closeable; import java.io.IOException; import java.nio.channels.FileChannel; -import java.nio.channels.SeekableByteChannel; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.nio.file.StandardOpenOption; import java.nio.file.attribute.FileAttribute; import java.nio.file.attribute.FileTime; import java.time.Instant; @@ -33,6 +26,7 @@ public class OpenCryptoFile implements Closeable { private final FileCloseListener listener; private final AtomicReference lastModified; + private final InUseFile inUseFile; private final Cryptor cryptor; private final FileHeaderHolder headerHolder; private final ChunkIO chunkIO; @@ -41,12 +35,12 @@ public class OpenCryptoFile implements Closeable { private final OpenCryptoFileComponent component; private final AtomicInteger openChannelsCount = new AtomicInteger(0); - private volatile SeekableByteChannel inUseFileChannel; @Inject public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHolder headerHolder, ChunkIO chunkIO, // @CurrentOpenFilePath AtomicReference currentFilePath, @OpenFileSize AtomicLong fileSize, // - @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component) { + @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component, // + InUseFile inUseFile) { this.listener = listener; this.cryptor = cryptor; this.headerHolder = headerHolder; @@ -55,6 +49,7 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol this.fileSize = fileSize; this.component = component; this.lastModified = lastModified; + this.inUseFile = inUseFile; } /** @@ -75,7 +70,7 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil var openChannels = openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { // in-use section - if (openChannels == 1 && isFileInUse(path)) { //add ignore mechanic + if (openChannels == 1 && inUseFile.checkOrOwn()) { //add ignore mechanic throw new FileIsInUseException(path); } ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); @@ -98,62 +93,6 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil return cleartextFileChannel; } - boolean isFileInUse(Path ciphertextPath) { - var inUseFilePath = getInUseFilePath(ciphertextPath); - try { - Object content = readInUseFile(inUseFilePath); - //check if file belongs to us - //if yes, do stuff and return false - //otherwise notify user and return true - return true; - } catch (NoSuchFileException e) { - createInUseFile(inUseFilePath); - } catch (FileTooBigException e) { - LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); - ownInUseFile(inUseFilePath); - } catch (IOException e) { - LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); - } - return false; - } - - Object readInUseFile(Path inUseFilePath) throws IOException { - var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); - //TODO: convert to JSON an extract info - return new Object(); - } - - void createInUseFile(Path inUseFilePath) { - try { - this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); - } catch (IOException e) { - LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); - } - } - - void ownInUseFile(Path inUseFilePath) { - try { - this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); - } catch (IOException e) { - LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); - } - } - - void deleteInUseFile() { - if (inUseFileChannel != null) { - try { - inUseFileChannel.close(); //TODO: DELETE_ON_CLOSE should clean up. Do we need a dedicated cleanup routine? - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } - - private Path getInUseFilePath(Path p) { - var ciphertextName = p.getFileName().toString(); - return p.resolveSibling(ciphertextName.substring(0, ciphertextName.length() - 3) + "c9l"); - } - //visible for testing void initFileHeader(EffectiveOpenOptions options, FileChannel ciphertextFileChannel) throws IOException { try { @@ -252,7 +191,7 @@ public void close() { var p = currentFilePath.get(); if (p != null) { listener.close(p, this); - deleteInUseFile(); + inUseFile.close(); } } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index c76d90c3..d04efce8 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -36,8 +36,6 @@ import java.util.function.Consumer; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -60,6 +58,7 @@ public class OpenCryptoFileTest { private OpenCryptoFileComponent openCryptoFileComponent = mock(OpenCryptoFileComponent.class); private ChannelComponent.Factory channelComponentFactory = mock(ChannelComponent.Factory.class); private ChannelComponent channelComponent = mock(ChannelComponent.class); + private InUseFile inUseFile = mock(InUseFile.class); @BeforeAll public static void setup() { @@ -75,11 +74,10 @@ public static void tearDown() throws IOException { @Test @DisplayName("on close(), trigger closeListener and delete lockFile") public void testClose() { - OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); - doNothing().when(openCryptoFile).deleteInUseFile(); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); openCryptoFile.close(); verify(closeListener).close(CURRENT_FILE_PATH.get(), openCryptoFile); - verify(openCryptoFile).deleteInUseFile(); + verify(inUseFile).close(); } // tests https://github.com/cryptomator/cryptofs/issues/51 @@ -89,8 +87,8 @@ public void testFailedFirstFileChannelImmediatelyCallsClose() { UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); - when(openCryptoFile.isFileInUse(CURRENT_FILE_PATH.get())).thenReturn(false); + when(inUseFile.checkOrOwn()).thenReturn(false); + OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { openCryptoFile.newFileChannel(options); @@ -103,8 +101,8 @@ public void testFailedFirstFileChannelImmediatelyCallsClose() { @DisplayName("if the file is in use, throw exception") public void testInUseFileThrowsException() { EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); - OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); - when(openCryptoFile.isFileInUse(CURRENT_FILE_PATH.get())).thenReturn(true); + when(inUseFile.checkOrOwn()).thenReturn(true); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); Assertions.assertThrows(FileIsInUseException.class, () -> { openCryptoFile.newFileChannel(options); @@ -126,12 +124,11 @@ public void testFailedSecondFileChannelDoesNothing() throws IOException { Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); + when(inUseFile.checkOrOwn()).thenReturn(false); EffectiveOpenOptions failingOptions = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(failingOptions.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); - when(openCryptoFile.isFileInUse(CURRENT_FILE_PATH.get())).thenReturn(false); - doNothing().when(openCryptoFile).deleteInUseFile(); + OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); try (var channel = openCryptoFile.newFileChannel(options)) { UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { @@ -154,9 +151,8 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); - OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent)); - when(openCryptoFile.isFileInUse(CURRENT_FILE_PATH.get())).thenReturn(false); - doNothing().when(openCryptoFile).deleteInUseFile(); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); + when(inUseFile.checkOrOwn()).thenReturn(false); openCryptoFile.newFileChannel(options); verify(cleartextChannel).truncate(0L); @@ -168,7 +164,7 @@ public class InitFilHeaderTests { EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); FileChannel cipherFileChannel = Mockito.mock(FileChannel.class, "cipherFilechannel"); - OpenCryptoFile inTest = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent); + OpenCryptoFile inTest = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); @Test @DisplayName("Skip file header init, if the file header already exists in memory") @@ -252,7 +248,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); + openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, realFileSize, lastModified, openCryptoFileComponent, inUseFile); cleartextFileChannel = mock(CleartextFileChannel.class); listener = new AtomicReference<>(); ciphertextChannel = new AtomicReference<>(); @@ -279,12 +275,11 @@ public void testGetSizeBeforeCreatingFileChannel() { public void createFileChannel() throws IOException { var attrs = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); - var openCryptoFileSpy = spy(openCryptoFile); - doReturn(false).when(openCryptoFileSpy).isFileInUse(any()); - FileChannel ch = openCryptoFileSpy.newFileChannel(options, attrs); + when(inUseFile.checkOrOwn()).thenReturn(false); + FileChannel ch = openCryptoFile.newFileChannel(options, attrs); Assertions.assertSame(cleartextFileChannel, ch); verify(chunkIO).registerChannel(ciphertextChannel.get(), true); - verify(openCryptoFileSpy).isFileInUse(CURRENT_FILE_PATH.get()); + verify(inUseFile).checkOrOwn(); } @Test From a07bc23b272dc89e4b56e270ef4ef6936b3ecca3 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 31 Jul 2025 16:35:37 +0200 Subject: [PATCH 05/95] add content to in use file (via properties) and fileInUseEvent --- .../cryptofs/event/FileIsInUseEvent.java | 17 +++++ .../cryptofs/event/FilesystemEvent.java | 10 ++- .../cryptomator/cryptofs/fh/InUseFile.java | 51 +++++++++++---- .../cryptofs/fh/InUseFileTest.java | 63 +++++++++++++++++++ 4 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java create mode 100644 src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java diff --git a/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java b/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java new file mode 100644 index 00000000..bead9204 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java @@ -0,0 +1,17 @@ +package org.cryptomator.cryptofs.event; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.Properties; + +public record FileIsInUseEvent(Instant timestamp, Path cleartext, Path ciphertext, Properties moreInfo) implements FilesystemEvent { + + public FileIsInUseEvent(Path cleartext, Path ciphertext, Properties moreInfo) { + this(Instant.now(), cleartext, ciphertext, moreInfo); + } + + @Override + public Instant getTimestamp() { + return timestamp; + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java b/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java index 7f1165ff..a7abeda3 100644 --- a/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java +++ b/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java @@ -16,14 +16,20 @@ * case DecryptionFailedEvent(Instant timestamp, Path ciphertext, Exception ex) -> //do stuff * //... other cases * } - * if( fse instanceof DecryptionFailedEvent(Instant timestamp, Path ciphertext, Exception ex) { + * if( fse instanceof DecryptionFailedEvent(Instant timestamp, Path ciphertext, Exception ex)) { * //do more stuff * } * }. * * @apiNote Events might have occured a long time ago in a galaxy far, far away... therefore, any feedback method is non-blocking and might fail due to changes in the filesystem. */ -public sealed interface FilesystemEvent permits BrokenDirFileEvent, BrokenFileNodeEvent, ConflictResolutionFailedEvent, ConflictResolvedEvent, DecryptionFailedEvent { +public sealed interface FilesystemEvent permits // + BrokenDirFileEvent, // + BrokenFileNodeEvent, // + ConflictResolutionFailedEvent, // + ConflictResolvedEvent, // + DecryptionFailedEvent, // + FileIsInUseEvent { /** * Gets the timestamp when the event occurred. diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index 0e1285eb..bc1af13a 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -3,43 +3,58 @@ import jakarta.inject.Inject; import org.cryptomator.cryptofs.common.FileTooBigException; import org.cryptomator.cryptofs.common.FileUtil; +import org.cryptomator.cryptofs.event.FileIsInUseEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.Properties; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; @OpenFileScoped public class InUseFile implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(InUseFile.class); + private final String fileSystemOwner; + private final Properties info; private final AtomicReference currentFilePath; + private final Consumer eventConsumer; private SeekableByteChannel inUseFileChannel; @Inject - public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath) { - //TODO: add notifier + public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, Consumer eventConsumer) { this.currentFilePath = currentFilePath; + this.eventConsumer = eventConsumer; + this.fileSystemOwner = System.getenv("USERDOMAIN") + "\"" + System.getenv("USERNAME"); //TODO: read from CryptoFileSystemProperties + this.info = new Properties(); + info.put("owner", fileSystemOwner); } boolean checkOrOwn() { var ciphertextPath = currentFilePath.get(); var inUseFilePath = getInUseFilePath(ciphertextPath); try { - Object content = readInUseFile(inUseFilePath); - //check if file belongs to us - //if yes, do stuff and return false - //otherwise notify user and return true - return true; + Properties content = readInUseFile(inUseFilePath); + if (content.get("owner").equals(fileSystemOwner)) { + //update timestamps + info.putAll(content); + return false; + } else { + eventConsumer.accept(new FileIsInUseEvent(Path.of("yadda"), ciphertextPath, info)); + return true; + } } catch (NoSuchFileException e) { LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); createInUseFile(inUseFilePath); @@ -52,10 +67,14 @@ boolean checkOrOwn() { return false; } - Object readInUseFile(Path inUseFilePath) throws IOException { + Properties readInUseFile(Path inUseFilePath) throws IOException { + //TODO: decryption var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); //TODO: convert to JSON an extract info - return new Object(); + // for now we use properties + var props = new Properties(); + props.load(new ByteArrayInputStream(bytes)); + return props; } void createInUseFile(Path inUseFilePath) { @@ -77,7 +96,10 @@ void ownInUseFile(Path inUseFilePath) { } void writeInUseInfo() throws IOException { - this.inUseFileChannel.write(ByteBuffer.wrap("Test String".getBytes(StandardCharsets.UTF_8))); + var rawInfo = new ByteArrayOutputStream(4_000); + info.store(rawInfo, "UNENCRYPTED Cryptomator inUse file"); + //TODO: encryption + this.inUseFileChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); } @@ -88,12 +110,15 @@ public void close() { inUseFileChannel.close(); //TODO: DELETE_ON_CLOSE should clean up. Do we need a dedicated cleanup routine? } catch (IOException e) { LOG.error("Unable to delete in-use-file. Must be cleaned manually."); - //throw new RuntimeException(e); } } } - private Path getInUseFilePath(Path p) { + /** + * @param p a path with a filename with a 3 character file extension + * @return a sibling path with the file extension replaced by "c9l" + */ + Path getInUseFilePath(Path p) { var ciphertextName = p.getFileName().toString(); return p.resolveSibling(ciphertextName.substring(0, ciphertextName.length() - 3) + "c9l"); } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java new file mode 100644 index 00000000..77303571 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -0,0 +1,63 @@ +package org.cryptomator.cryptofs.fh; + +import org.cryptomator.cryptofs.event.FilesystemEvent; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.mockito.Mockito.mock; + +public class InUseFileTest { + + /* + To test: + * checkOrOwn + * readInUseFile + * createInUseFile + * writeInUseFile + * ownInUseFile + * close + * getInUseFilePath + */ + + + @Test + @DisplayName("Lock files end with .c9l and are in the same directory as the content file") + public void testGetInUseFilePath(@TempDir Path tmpDir) { + var currentPath = mock(Path.class, "currentPath"); + var currentFilePath = new AtomicReference(currentPath); + Consumer eventConsumer = e -> {}; + InUseFile inUseFile = new InUseFile(currentFilePath, eventConsumer); + + var path = tmpDir.resolve("hello.abc"); + var result = inUseFile.getInUseFilePath(path); + + Assertions.assertTrue(result.toString().endsWith(".c9l")); + Assertions.assertEquals(path.getParent(), result.getParent()); + + } + + @Test + @DisplayName("Lock files also work with direct root childs") + public void testGetInUseFilePathWithRoot(@TempDir Path tmpDir) { + var currentPath = mock(Path.class, "currentPath"); + var currentFilePath = new AtomicReference(currentPath); + Consumer eventConsumer = e -> {}; + InUseFile inUseFile = new InUseFile(currentFilePath, eventConsumer); + var rootChild = tmpDir.getRoot().resolve("test3000.abc"); + + var result = inUseFile.getInUseFilePath(rootChild); + + Assertions.assertTrue(result.toString().endsWith(".c9l")); + Assertions.assertEquals(rootChild.getParent(), result.getParent()); + + } + + + +} From c421f2a5b1570a6e3730b0b3362f34719789f821 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 1 Aug 2025 15:29:20 +0200 Subject: [PATCH 06/95] add a validate method and more tests --- .../cryptomator/cryptofs/fh/InUseFile.java | 22 +++- .../cryptofs/fh/InUseFileTest.java | 110 ++++++++++++++++-- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index bc1af13a..1f813d57 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -1,6 +1,7 @@ package org.cryptomator.cryptofs.fh; import jakarta.inject.Inject; +import org.cryptomator.cryptofs.CryptoFileSystemProperties; import org.cryptomator.cryptofs.common.FileTooBigException; import org.cryptomator.cryptofs.common.FileUtil; import org.cryptomator.cryptofs.event.FileIsInUseEvent; @@ -34,10 +35,10 @@ public class InUseFile implements Closeable { private SeekableByteChannel inUseFileChannel; @Inject - public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, Consumer eventConsumer) { + public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, Consumer eventConsumer, CryptoFileSystemProperties fsProps) { this.currentFilePath = currentFilePath; this.eventConsumer = eventConsumer; - this.fileSystemOwner = System.getenv("USERDOMAIN") + "\"" + System.getenv("USERNAME"); //TODO: read from CryptoFileSystemProperties + this.fileSystemOwner = (String) fsProps.getOrDefault("owner", "cryptobot"); this.info = new Properties(); info.put("owner", fileSystemOwner); } @@ -47,6 +48,7 @@ boolean checkOrOwn() { var inUseFilePath = getInUseFilePath(ciphertextPath); try { Properties content = readInUseFile(inUseFilePath); + validate(content); if (content.get("owner").equals(fileSystemOwner)) { //update timestamps info.putAll(content); @@ -57,8 +59,9 @@ boolean checkOrOwn() { } } catch (NoSuchFileException e) { LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); + //TODO: delay creation with a CompletionStage (to prevent spam) createInUseFile(inUseFilePath); - } catch (FileTooBigException e) { + } catch (FileTooBigException | IllegalArgumentException e) { LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); ownInUseFile(inUseFilePath); } catch (IOException e) { @@ -67,14 +70,22 @@ boolean checkOrOwn() { return false; } + private void validate(Properties content) throws IllegalArgumentException { + if (!content.containsKey("owner")) { + throw new IllegalArgumentException("Invalid in-use-file. Missing key \"owner\""); + } + } + Properties readInUseFile(Path inUseFilePath) throws IOException { //TODO: decryption var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); //TODO: convert to JSON an extract info // for now we use properties var props = new Properties(); - props.load(new ByteArrayInputStream(bytes)); - return props; + try (var stream = new ByteArrayInputStream(bytes)) { + props.load(stream); + return props; + } } void createInUseFile(Path inUseFilePath) { @@ -107,6 +118,7 @@ void writeInUseInfo() throws IOException { public void close() { if (inUseFileChannel != null) { try { + //delay closing with a completionStage inUseFileChannel.close(); //TODO: DELETE_ON_CLOSE should clean up. Do we need a dedicated cleanup routine? } catch (IOException e) { LOG.error("Unable to delete in-use-file. Must be cleaned manually."); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index 77303571..583f1cfd 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -1,16 +1,32 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.CryptoFileSystemProperties; +import org.cryptomator.cryptofs.event.FileIsInUseEvent; import org.cryptomator.cryptofs.event.FilesystemEvent; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; +import java.io.IOException; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.util.Properties; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class InUseFileTest { @@ -25,14 +41,97 @@ public class InUseFileTest { * getInUseFilePath */ + AtomicReference currentFilePath = new AtomicReference<>(); + Consumer eventConsumer = mock(Consumer.class); + CryptoFileSystemProperties fsProps = mock(CryptoFileSystemProperties.class); + InUseFile inUseFile; + + @BeforeEach + public void beforeEach() { + when(fsProps.getOrDefault("owner", "cryptobot")).thenReturn("cryptobot"); + inUseFile = new InUseFile(currentFilePath, eventConsumer, fsProps); + } + + @Test + @DisplayName("CheckOrOwn for existing, valid inUseFile with same owner") + public void testCheckOrOwnReadExistingSameOwner() throws IOException { + var inUseFileSpy = spy(inUseFile); + + var inUseInfo = new Properties(); + inUseInfo.put("owner", "cryptobot"); + Path inUsePath = mock(Path.class, "inUseFilePath"); + doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); + doReturn(inUseInfo).when(inUseFileSpy).readInUseFile(inUsePath); + + var isInUse = inUseFileSpy.checkOrOwn(); + + Assertions.assertFalse(isInUse); + verify(inUseFileSpy, never()).createInUseFile(inUsePath); + verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + + //TODO: check, that inUse file is updated + } + + @Test + @DisplayName("CheckOrOwn for existing, valid inUseFile with different owner") + public void testCheckOrOwnReadExistingDifferentOwner() throws IOException { + var inUseFileSpy = spy(inUseFile); + + var inUseInfo = new Properties(); + inUseInfo.put("owner", "cryptobot3000"); + Path inUsePath = mock(Path.class, "inUseFilePath"); + doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); + doReturn(inUseInfo).when(inUseFileSpy).readInUseFile(inUsePath); + var isInUse = inUseFileSpy.checkOrOwn(); + + Assertions.assertTrue(isInUse); + verify(inUseFileSpy, never()).createInUseFile(inUsePath); + verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); + } + + @Test + @DisplayName("CheckOrOwn for existing, invalid inUseFile owns it") + public void testCheckOrOwnReadExistingInvalid() throws IOException { + var inUseFileSpy = spy(inUseFile); + + var inUseInfo = new Properties(); + + Path inUsePath = mock(Path.class, "inUseFilePath"); + doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); + doReturn(inUseInfo).when(inUseFileSpy).readInUseFile(inUsePath); + doNothing().when(inUseFileSpy).ownInUseFile(inUsePath); + var isInUse = inUseFileSpy.checkOrOwn(); + + Assertions.assertFalse(isInUse); + verify(inUseFileSpy).ownInUseFile(inUsePath); + verify(inUseFileSpy, never()).createInUseFile(inUsePath); + } + + @Test + @DisplayName("CheckOrOwn creates inUseFile if it does not exist") + public void testCheckOrOwnCreateNew() throws IOException { + var inUseFileSpy = spy(inUseFile); + + Path inUsePath = mock(Path.class, "inUseFilePath"); + doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); + doThrow(NoSuchFileException.class).when(inUseFileSpy).readInUseFile(inUsePath); + doNothing().when(inUseFileSpy).createInUseFile(inUsePath); + var isInUse = inUseFileSpy.checkOrOwn(); + + Assertions.assertFalse(isInUse); + verify(inUseFileSpy).createInUseFile(inUsePath); + verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + } + @Test @DisplayName("Lock files end with .c9l and are in the same directory as the content file") public void testGetInUseFilePath(@TempDir Path tmpDir) { var currentPath = mock(Path.class, "currentPath"); - var currentFilePath = new AtomicReference(currentPath); - Consumer eventConsumer = e -> {}; - InUseFile inUseFile = new InUseFile(currentFilePath, eventConsumer); + currentFilePath.set(currentPath); var path = tmpDir.resolve("hello.abc"); var result = inUseFile.getInUseFilePath(path); @@ -46,9 +145,7 @@ public void testGetInUseFilePath(@TempDir Path tmpDir) { @DisplayName("Lock files also work with direct root childs") public void testGetInUseFilePathWithRoot(@TempDir Path tmpDir) { var currentPath = mock(Path.class, "currentPath"); - var currentFilePath = new AtomicReference(currentPath); - Consumer eventConsumer = e -> {}; - InUseFile inUseFile = new InUseFile(currentFilePath, eventConsumer); + currentFilePath.set(currentPath); var rootChild = tmpDir.getRoot().resolve("test3000.abc"); var result = inUseFile.getInUseFilePath(rootChild); @@ -59,5 +156,4 @@ public void testGetInUseFilePathWithRoot(@TempDir Path tmpDir) { } - } From 9ac3c6a77fb1aa81a28901429a4d4b69b6c617b4 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 1 Aug 2025 17:09:58 +0200 Subject: [PATCH 07/95] add integration test --- .../CryptoFileChannelWriteReadIntegrationTest.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java index 949ae7fc..0b98cd19 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java @@ -151,11 +151,12 @@ public class PlatformIndependent { private FileSystem fileSystem; private Path file; + private Path vaultPath; @BeforeAll public void beforeAll() throws IOException, MasterkeyLoadingFailedException { inMemoryFs = Jimfs.newFileSystem(); - Path vaultPath = inMemoryFs.getPath("vault"); + vaultPath = inMemoryFs.getPath("vault"); Files.createDirectories(vaultPath); MasterkeyLoader keyLoader = Mockito.mock(MasterkeyLoader.class); Mockito.when(keyLoader.loadKey(Mockito.any())).thenAnswer(ignored -> new Masterkey(new byte[64])); @@ -176,6 +177,17 @@ public void afterEach() throws IOException { Files.deleteIfExists(file); } + @Test + @DisplayName("Opening a file channel creates an in-use file and removes it on close") + public void testOpeningFCCreatesInUseFile() throws IOException { + try (var writer = FileChannel.open(file, CREATE, WRITE)) { + var inUseFileExists = Files.walk(vaultPath.resolve("d")).anyMatch( p -> p.getFileName().toString().endsWith(".c9l")); + Assertions.assertTrue(inUseFileExists); + } + var inUseFileExists = Files.walk(vaultPath.resolve("d")).anyMatch( p -> p.getFileName().toString().endsWith(".c9l")); + Assertions.assertFalse(inUseFileExists); + } + //https://github.com/cryptomator/cryptofs/issues/173 @Test @DisplayName("First incomplete, then completely filled chunks are stored completely") From da573e7cead4f29222084cc42aededc1766ebcb6 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 1 Aug 2025 17:10:57 +0200 Subject: [PATCH 08/95] add close unit tests --- .../cryptomator/cryptofs/fh/InUseFile.java | 52 ++++++++++++------- .../cryptofs/fh/InUseFileTest.java | 41 ++++++++++++--- 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index 1f813d57..c05f97ab 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -43,20 +43,17 @@ public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, Con info.put("owner", fileSystemOwner); } - boolean checkOrOwn() { + synchronized boolean checkOrOwn() { var ciphertextPath = currentFilePath.get(); var inUseFilePath = getInUseFilePath(ciphertextPath); try { Properties content = readInUseFile(inUseFilePath); - validate(content); - if (content.get("owner").equals(fileSystemOwner)) { - //update timestamps - info.putAll(content); - return false; - } else { + if (!content.get("owner").equals(fileSystemOwner)) { eventConsumer.accept(new FileIsInUseEvent(Path.of("yadda"), ciphertextPath, info)); return true; } + //update timestamps + info.putAll(content); } catch (NoSuchFileException e) { LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); //TODO: delay creation with a CompletionStage (to prevent spam) @@ -70,13 +67,7 @@ boolean checkOrOwn() { return false; } - private void validate(Properties content) throws IllegalArgumentException { - if (!content.containsKey("owner")) { - throw new IllegalArgumentException("Invalid in-use-file. Missing key \"owner\""); - } - } - - Properties readInUseFile(Path inUseFilePath) throws IOException { + Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { //TODO: decryption var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); //TODO: convert to JSON an extract info @@ -84,13 +75,21 @@ Properties readInUseFile(Path inUseFilePath) throws IOException { var props = new Properties(); try (var stream = new ByteArrayInputStream(bytes)) { props.load(stream); + validate(props); return props; } } + private void validate(Properties content) throws IllegalArgumentException { + if (!content.containsKey("owner")) { + throw new IllegalArgumentException("Invalid in-use-file. Missing key \"owner\""); + } + //TODO: more keys + } + void createInUseFile(Path inUseFilePath) { try { - this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); //TODO: delete on close? writeInUseInfo(); } catch (IOException e) { LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); @@ -99,7 +98,7 @@ void createInUseFile(Path inUseFilePath) { void ownInUseFile(Path inUseFilePath) { try { - this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.DELETE_ON_CLOSE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); + this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); //TODO: delete on close? writeInUseInfo(); } catch (IOException e) { LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); @@ -113,13 +112,20 @@ void writeInUseInfo() throws IOException { this.inUseFileChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); } + //for testing + void deleteInUseFile(Path inUseFilePath) throws IOException { + Files.deleteIfExists(inUseFilePath); + } + @Override - public void close() { + public synchronized void close() { if (inUseFileChannel != null) { try { //delay closing with a completionStage - inUseFileChannel.close(); //TODO: DELETE_ON_CLOSE should clean up. Do we need a dedicated cleanup routine? + inUseFileChannel.close(); + var inUsePath = getInUseFilePath(currentFilePath.get()); + deleteInUseFile(inUsePath); } catch (IOException e) { LOG.error("Unable to delete in-use-file. Must be cleaned manually."); } @@ -134,4 +140,14 @@ Path getInUseFilePath(Path p) { var ciphertextName = p.getFileName().toString(); return p.resolveSibling(ciphertextName.substring(0, ciphertextName.length() - 3) + "c9l"); } + + //-- for testing only + + InUseFile(AtomicReference currentFilePath, Consumer eventConsumer, String fileSystemOwner, SeekableByteChannel inUseFileChannel, Properties info) { + this.currentFilePath = currentFilePath; + this.eventConsumer = eventConsumer; + this.fileSystemOwner = fileSystemOwner; + this.inUseFileChannel = inUseFileChannel; + this.info = info; + } } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index 583f1cfd..ab87eea1 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -1,6 +1,5 @@ package org.cryptomator.cryptofs.fh; -import org.cryptomator.cryptofs.CryptoFileSystemProperties; import org.cryptomator.cryptofs.event.FileIsInUseEvent; import org.cryptomator.cryptofs.event.FilesystemEvent; import org.junit.jupiter.api.Assertions; @@ -12,6 +11,7 @@ import org.mockito.ArgumentMatchers; import java.io.IOException; +import java.nio.channels.SeekableByteChannel; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.Properties; @@ -26,7 +26,6 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; public class InUseFileTest { @@ -43,13 +42,13 @@ public class InUseFileTest { AtomicReference currentFilePath = new AtomicReference<>(); Consumer eventConsumer = mock(Consumer.class); - CryptoFileSystemProperties fsProps = mock(CryptoFileSystemProperties.class); + SeekableByteChannel inUseChannel = mock(SeekableByteChannel.class); + Properties info = new Properties(); InUseFile inUseFile; @BeforeEach public void beforeEach() { - when(fsProps.getOrDefault("owner", "cryptobot")).thenReturn("cryptobot"); - inUseFile = new InUseFile(currentFilePath, eventConsumer, fsProps); + inUseFile = new InUseFile(currentFilePath, eventConsumer, "cryptobot", inUseChannel, info); } @Test @@ -97,11 +96,9 @@ public void testCheckOrOwnReadExistingDifferentOwner() throws IOException { public void testCheckOrOwnReadExistingInvalid() throws IOException { var inUseFileSpy = spy(inUseFile); - var inUseInfo = new Properties(); - Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); - doReturn(inUseInfo).when(inUseFileSpy).readInUseFile(inUsePath); + doThrow(IllegalArgumentException.class).when(inUseFileSpy).readInUseFile(inUsePath); doNothing().when(inUseFileSpy).ownInUseFile(inUsePath); var isInUse = inUseFileSpy.checkOrOwn(); @@ -152,6 +149,34 @@ public void testGetInUseFilePathWithRoot(@TempDir Path tmpDir) { Assertions.assertTrue(result.toString().endsWith(".c9l")); Assertions.assertEquals(rootChild.getParent(), result.getParent()); + } + + @Test + @DisplayName("Close closes inUseFileChannel and removes inUse file") + public void testClose() throws IOException { + var inUseFileSpy = spy(inUseFile); + Path inUsePath = mock(Path.class, "inUseFilePath"); + doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); + doNothing().when(inUseFileSpy).deleteInUseFile(any()); + + inUseFileSpy.close(); + + verify(inUseChannel).close(); + verify(inUseFileSpy).deleteInUseFile(inUsePath); + } + + @Test + @DisplayName("Close does not propagate IO exception") + public void testCloseFailing() throws IOException { + var inUseFileSpy = spy(inUseFile); + Path inUsePath = mock(Path.class, "inUseFilePath"); + doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); + doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); + + Assertions.assertDoesNotThrow(inUseFileSpy::close); + + doThrow(IOException.class).when(inUseChannel).close(); + Assertions.assertDoesNotThrow(inUseFileSpy::close); } From c3362833f36477f36596836837f98f5f1b7c9f2e Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Sun, 3 Aug 2025 12:33:44 +0200 Subject: [PATCH 09/95] on move, move also inUse file --- .../org/cryptomator/cryptofs/CiphertextFilePath.java | 5 +++++ .../cryptomator/cryptofs/CryptoFileSystemImpl.java | 3 +++ .../org/cryptomator/cryptofs/common/Constants.java | 1 + .../java/org/cryptomator/cryptofs/fh/InUseFile.java | 12 +++++++----- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java b/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java index 81f955d6..d627eb33 100644 --- a/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java +++ b/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java @@ -1,6 +1,7 @@ package org.cryptomator.cryptofs; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.fh.InUseFile; import java.io.IOException; import java.nio.file.Path; @@ -41,6 +42,10 @@ public Path getInflatedNamePath() { return path.resolve(Constants.INFLATED_FILE_NAME); } + public Path getInUseFilePath() { + return InUseFile.getInUseFilePath(getFilePath()); + } + @Override public int hashCode() { return Objects.hash(path, deflatedFileName); diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 1b320089..29ce18d6 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -610,6 +610,9 @@ private void moveFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, Co ciphertextTarget.persistLongFileName(); } Files.move(ciphertextSource.getFilePath(), ciphertextTarget.getFilePath(), options); + //TODO: test + // question: what should happen + Files.move(ciphertextSource.getInUseFilePath(), ciphertextTarget.getInUseFilePath()); if (ciphertextSource.isShortened()) { Files.walkFileTree(ciphertextSource.getRawPath(), DeletingFileVisitor.INSTANCE); } diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 291eaf22..18d991ae 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -21,6 +21,7 @@ private Constants() { public static final String CRYPTOMATOR_FILE_SUFFIX = ".c9r"; public static final String DEFLATED_FILE_SUFFIX = ".c9s"; + public static final String INUSE_FILE_SUFFIX = ".c9l"; public static final String DIR_FILE_NAME = "dir.c9r"; public static final String SYMLINK_FILE_NAME = "symlink.c9r"; public static final String CONTENTS_FILE_NAME = "contents.c9r"; diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index c05f97ab..aaa11bba 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -2,6 +2,7 @@ import jakarta.inject.Inject; import org.cryptomator.cryptofs.CryptoFileSystemProperties; +import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.FileTooBigException; import org.cryptomator.cryptofs.common.FileUtil; import org.cryptomator.cryptofs.event.FileIsInUseEvent; @@ -133,12 +134,13 @@ public synchronized void close() { } /** - * @param p a path with a filename with a 3 character file extension - * @return a sibling path with the file extension replaced by "c9l" + * @param p a path with a filename ending with {@value Constants#CRYPTOMATOR_FILE_SUFFIX} + * @return a sibling path with the file extension {@value Constants#INUSE_FILE_SUFFIX} */ - Path getInUseFilePath(Path p) { - var ciphertextName = p.getFileName().toString(); - return p.resolveSibling(ciphertextName.substring(0, ciphertextName.length() - 3) + "c9l"); + public static Path getInUseFilePath(Path p) { + var tmp = p.getFileName().toString(); + var fileName = tmp.substring(0, tmp.length() - Constants.CRYPTOMATOR_FILE_SUFFIX.length()); + return p.resolveSibling(fileName + Constants.INUSE_FILE_SUFFIX); } //-- for testing only From 531300e45debad868b2cba510679717565bda136 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 6 Aug 2025 17:04:13 +0200 Subject: [PATCH 10/95] refactoring: * more static methods * renaming methods --- .../cryptomator/cryptofs/fh/InUseFile.java | 36 +++-- .../cryptofs/fh/OpenCryptoFile.java | 2 +- .../cryptofs/fh/InUseFileTest.java | 123 ++++++++++-------- .../cryptofs/fh/OpenCryptoFileTest.java | 12 +- 4 files changed, 99 insertions(+), 74 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index aaa11bba..4fc29a93 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -44,17 +44,15 @@ public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, Con info.put("owner", fileSystemOwner); } - synchronized boolean checkOrOwn() { + synchronized boolean tryMarkInUse() { var ciphertextPath = currentFilePath.get(); - var inUseFilePath = getInUseFilePath(ciphertextPath); + var inUseFilePath = computeInUseFilePath(ciphertextPath); try { - Properties content = readInUseFile(inUseFilePath); - if (!content.get("owner").equals(fileSystemOwner)) { + if (isInUse(inUseFilePath, fileSystemOwner)) { eventConsumer.accept(new FileIsInUseEvent(Path.of("yadda"), ciphertextPath, info)); return true; } - //update timestamps - info.putAll(content); + //TODO: update timestamps } catch (NoSuchFileException e) { LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); //TODO: delay creation with a CompletionStage (to prevent spam) @@ -68,7 +66,25 @@ synchronized boolean checkOrOwn() { return false; } - Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { + /** + * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running cryptofile system. + * + * @param inUseFilePath + * @param fileSystemOwner name of the filesystem owner + * @return {@code true} if the in-use-file exists, but owned by different user + * @throws IOException if the in-use-file does not exist or cannot be read + * @throws IllegalArgumentException if the in-use-file is invalid + */ + public static boolean isInUse(Path inUseFilePath, String fileSystemOwner) throws IOException, IllegalArgumentException { + Properties content = readInUseFile(inUseFilePath); + if(!content.get("owner").equals(fileSystemOwner)) { + //TODO: check also timestamps + return true; + } + return false; + } + + static Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { //TODO: decryption var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); //TODO: convert to JSON an extract info @@ -81,7 +97,7 @@ Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgument } } - private void validate(Properties content) throws IllegalArgumentException { + private static void validate(Properties content) throws IllegalArgumentException { if (!content.containsKey("owner")) { throw new IllegalArgumentException("Invalid in-use-file. Missing key \"owner\""); } @@ -125,7 +141,7 @@ public synchronized void close() { try { //delay closing with a completionStage inUseFileChannel.close(); - var inUsePath = getInUseFilePath(currentFilePath.get()); + var inUsePath = computeInUseFilePath(currentFilePath.get()); deleteInUseFile(inUsePath); } catch (IOException e) { LOG.error("Unable to delete in-use-file. Must be cleaned manually."); @@ -137,7 +153,7 @@ public synchronized void close() { * @param p a path with a filename ending with {@value Constants#CRYPTOMATOR_FILE_SUFFIX} * @return a sibling path with the file extension {@value Constants#INUSE_FILE_SUFFIX} */ - public static Path getInUseFilePath(Path p) { + public static Path computeInUseFilePath(Path p) { var tmp = p.getFileName().toString(); var fileName = tmp.substring(0, tmp.length() - Constants.CRYPTOMATOR_FILE_SUFFIX.length()); return p.resolveSibling(fileName + Constants.INUSE_FILE_SUFFIX); diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index dd5d845c..480561f6 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -70,7 +70,7 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil var openChannels = openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { // in-use section - if (openChannels == 1 && inUseFile.checkOrOwn()) { //add ignore mechanic + if (openChannels == 1 && inUseFile.tryMarkInUse()) { //add ignore mechanic throw new FileIsInUseException(path); } ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index ab87eea1..fbf15abc 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -19,10 +19,11 @@ import java.util.function.Consumer; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -53,85 +54,92 @@ public void beforeEach() { @Test @DisplayName("CheckOrOwn for existing, valid inUseFile with same owner") - public void testCheckOrOwnReadExistingSameOwner() throws IOException { + public void testTryMarkInUseReadExistingSameOwner() throws IOException { var inUseFileSpy = spy(inUseFile); var inUseInfo = new Properties(); inUseInfo.put("owner", "cryptobot"); Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); - doReturn(inUseInfo).when(inUseFileSpy).readInUseFile(inUsePath); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenReturn(inUseInfo); + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - var isInUse = inUseFileSpy.checkOrOwn(); + var isInUse = inUseFileSpy.tryMarkInUse(); - Assertions.assertFalse(isInUse); - verify(inUseFileSpy, never()).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).ownInUseFile(inUsePath); - - //TODO: check, that inUse file is updated + Assertions.assertFalse(isInUse); + verify(inUseFileSpy, never()).createInUseFile(inUsePath); + verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + //TODO: check, that inUse file is updated + } } @Test @DisplayName("CheckOrOwn for existing, valid inUseFile with different owner") - public void testCheckOrOwnReadExistingDifferentOwner() throws IOException { + public void testTryMarkInUseReadExistingDifferentOwner() throws IOException { var inUseFileSpy = spy(inUseFile); var inUseInfo = new Properties(); inUseInfo.put("owner", "cryptobot3000"); Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); - doReturn(inUseInfo).when(inUseFileSpy).readInUseFile(inUsePath); - var isInUse = inUseFileSpy.checkOrOwn(); - - Assertions.assertTrue(isInUse); - verify(inUseFileSpy, never()).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).ownInUseFile(inUsePath); - - var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent; - verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenReturn(inUseInfo); + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + + var isInUse = inUseFileSpy.tryMarkInUse(); + + Assertions.assertTrue(isInUse); + verify(inUseFileSpy, never()).createInUseFile(inUsePath); + verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); + } } @Test @DisplayName("CheckOrOwn for existing, invalid inUseFile owns it") - public void testCheckOrOwnReadExistingInvalid() throws IOException { + public void testTryMarkInUseReadExistingInvalid() throws IOException { var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); - doThrow(IllegalArgumentException.class).when(inUseFileSpy).readInUseFile(inUsePath); doNothing().when(inUseFileSpy).ownInUseFile(inUsePath); - var isInUse = inUseFileSpy.checkOrOwn(); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); + classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(IllegalArgumentException.class); + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + + var isInUse = inUseFileSpy.tryMarkInUse(); - Assertions.assertFalse(isInUse); - verify(inUseFileSpy).ownInUseFile(inUsePath); - verify(inUseFileSpy, never()).createInUseFile(inUsePath); + Assertions.assertFalse(isInUse); + verify(inUseFileSpy).ownInUseFile(inUsePath); + verify(inUseFileSpy, never()).createInUseFile(inUsePath); + } } @Test @DisplayName("CheckOrOwn creates inUseFile if it does not exist") - public void testCheckOrOwnCreateNew() throws IOException { + public void testTryMarkInUseCreateNew() throws IOException { var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); - doThrow(NoSuchFileException.class).when(inUseFileSpy).readInUseFile(inUsePath); doNothing().when(inUseFileSpy).createInUseFile(inUsePath); - var isInUse = inUseFileSpy.checkOrOwn(); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); + classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(NoSuchFileException.class); + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + + var isInUse = inUseFileSpy.tryMarkInUse(); - Assertions.assertFalse(isInUse); - verify(inUseFileSpy).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + Assertions.assertFalse(isInUse); + verify(inUseFileSpy).createInUseFile(inUsePath); + verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + } } @Test @DisplayName("Lock files end with .c9l and are in the same directory as the content file") - public void testGetInUseFilePath(@TempDir Path tmpDir) { - var currentPath = mock(Path.class, "currentPath"); - currentFilePath.set(currentPath); - + public void testComputeInUseFilePath(@TempDir Path tmpDir) { var path = tmpDir.resolve("hello.abc"); - var result = inUseFile.getInUseFilePath(path); + var result = InUseFile.computeInUseFilePath(path); Assertions.assertTrue(result.toString().endsWith(".c9l")); Assertions.assertEquals(path.getParent(), result.getParent()); @@ -140,12 +148,10 @@ public void testGetInUseFilePath(@TempDir Path tmpDir) { @Test @DisplayName("Lock files also work with direct root childs") - public void testGetInUseFilePathWithRoot(@TempDir Path tmpDir) { - var currentPath = mock(Path.class, "currentPath"); - currentFilePath.set(currentPath); + public void testComputeInUseFilePathWithRoot(@TempDir Path tmpDir) { var rootChild = tmpDir.getRoot().resolve("test3000.abc"); - var result = inUseFile.getInUseFilePath(rootChild); + var result = InUseFile.computeInUseFilePath(rootChild); Assertions.assertTrue(result.toString().endsWith(".c9l")); Assertions.assertEquals(rootChild.getParent(), result.getParent()); @@ -156,13 +162,15 @@ public void testGetInUseFilePathWithRoot(@TempDir Path tmpDir) { public void testClose() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); - doNothing().when(inUseFileSpy).deleteInUseFile(any()); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + doNothing().when(inUseFileSpy).deleteInUseFile(any()); - inUseFileSpy.close(); + inUseFileSpy.close(); - verify(inUseChannel).close(); - verify(inUseFileSpy).deleteInUseFile(inUsePath); + verify(inUseChannel).close(); + verify(inUseFileSpy).deleteInUseFile(inUsePath); + } } @Test @@ -170,14 +178,15 @@ public void testClose() throws IOException { public void testCloseFailing() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(inUsePath).when(inUseFileSpy).getInUseFilePath(any()); - doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); - - Assertions.assertDoesNotThrow(inUseFileSpy::close); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); - doThrow(IOException.class).when(inUseChannel).close(); - Assertions.assertDoesNotThrow(inUseFileSpy::close); + Assertions.assertDoesNotThrow(inUseFileSpy::close); + doThrow(IOException.class).when(inUseChannel).close(); + Assertions.assertDoesNotThrow(inUseFileSpy::close); + } } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index d04efce8..467451a2 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -87,7 +87,7 @@ public void testFailedFirstFileChannelImmediatelyCallsClose() { UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - when(inUseFile.checkOrOwn()).thenReturn(false); + when(inUseFile.tryMarkInUse()).thenReturn(false); OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { @@ -101,7 +101,7 @@ public void testFailedFirstFileChannelImmediatelyCallsClose() { @DisplayName("if the file is in use, throw exception") public void testInUseFileThrowsException() { EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); - when(inUseFile.checkOrOwn()).thenReturn(true); + when(inUseFile.tryMarkInUse()).thenReturn(true); OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); Assertions.assertThrows(FileIsInUseException.class, () -> { @@ -124,7 +124,7 @@ public void testFailedSecondFileChannelDoesNothing() throws IOException { Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); - when(inUseFile.checkOrOwn()).thenReturn(false); + when(inUseFile.tryMarkInUse()).thenReturn(false); EffectiveOpenOptions failingOptions = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(failingOptions.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); @@ -152,7 +152,7 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); - when(inUseFile.checkOrOwn()).thenReturn(false); + when(inUseFile.tryMarkInUse()).thenReturn(false); openCryptoFile.newFileChannel(options); verify(cleartextChannel).truncate(0L); @@ -275,11 +275,11 @@ public void testGetSizeBeforeCreatingFileChannel() { public void createFileChannel() throws IOException { var attrs = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); - when(inUseFile.checkOrOwn()).thenReturn(false); + when(inUseFile.tryMarkInUse()).thenReturn(false); FileChannel ch = openCryptoFile.newFileChannel(options, attrs); Assertions.assertSame(cleartextFileChannel, ch); verify(chunkIO).registerChannel(ciphertextChannel.get(), true); - verify(inUseFile).checkOrOwn(); + verify(inUseFile).tryMarkInUse(); } @Test From 128b8575d5fa19f9372b4c1382ee80a8b86663c6 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 7 Aug 2025 14:52:11 +0200 Subject: [PATCH 11/95] reduce inUseFile impl for moveFile to a TODO comment --- .../java/org/cryptomator/cryptofs/CiphertextFilePath.java | 4 ---- .../java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java b/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java index d627eb33..a1e9c7dd 100644 --- a/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java +++ b/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java @@ -42,10 +42,6 @@ public Path getInflatedNamePath() { return path.resolve(Constants.INFLATED_FILE_NAME); } - public Path getInUseFilePath() { - return InUseFile.getInUseFilePath(getFilePath()); - } - @Override public int hashCode() { return Objects.hash(path, deflatedFileName); diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 29ce18d6..783e78e6 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -605,14 +605,12 @@ private void moveFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, Co CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { + //TODO: fail, if foreign inUseFile exists if (ciphertextTarget.isShortened()) { Files.createDirectories(ciphertextTarget.getRawPath()); ciphertextTarget.persistLongFileName(); } Files.move(ciphertextSource.getFilePath(), ciphertextTarget.getFilePath(), options); - //TODO: test - // question: what should happen - Files.move(ciphertextSource.getInUseFilePath(), ciphertextTarget.getInUseFilePath()); if (ciphertextSource.isShortened()) { Files.walkFileTree(ciphertextSource.getRawPath(), DeletingFileVisitor.INSTANCE); } From 8de1fbaf097ccb2855779fec34706f80c561b791 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 7 Aug 2025 15:00:00 +0200 Subject: [PATCH 12/95] change in-use-file extension to ".c9u" --- .../java/org/cryptomator/cryptofs/common/Constants.java | 2 +- .../CryptoFileChannelWriteReadIntegrationTest.java | 9 +++++---- .../java/org/cryptomator/cryptofs/fh/InUseFileTest.java | 7 ++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 18d991ae..1647c927 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -21,7 +21,7 @@ private Constants() { public static final String CRYPTOMATOR_FILE_SUFFIX = ".c9r"; public static final String DEFLATED_FILE_SUFFIX = ".c9s"; - public static final String INUSE_FILE_SUFFIX = ".c9l"; + public static final String INUSE_FILE_SUFFIX = ".c9u"; public static final String DIR_FILE_NAME = "dir.c9r"; public static final String SYMLINK_FILE_NAME = "symlink.c9r"; public static final String CONTENTS_FILE_NAME = "contents.c9r"; diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java index 0b98cd19..5b6ec297 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java @@ -9,6 +9,7 @@ package org.cryptomator.cryptofs; import com.google.common.jimfs.Jimfs; +import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.util.ByteBuffers; import org.cryptomator.cryptolib.api.Masterkey; import org.cryptomator.cryptolib.api.MasterkeyLoader; @@ -181,11 +182,11 @@ public void afterEach() throws IOException { @DisplayName("Opening a file channel creates an in-use file and removes it on close") public void testOpeningFCCreatesInUseFile() throws IOException { try (var writer = FileChannel.open(file, CREATE, WRITE)) { - var inUseFileExists = Files.walk(vaultPath.resolve("d")).anyMatch( p -> p.getFileName().toString().endsWith(".c9l")); - Assertions.assertTrue(inUseFileExists); + var inUseFiles = Files.walk(vaultPath.resolve("d")).filter( p -> p.getFileName().toString().endsWith(Constants.INUSE_FILE_SUFFIX)).toList(); + Assertions.assertEquals(1, inUseFiles.size()); } - var inUseFileExists = Files.walk(vaultPath.resolve("d")).anyMatch( p -> p.getFileName().toString().endsWith(".c9l")); - Assertions.assertFalse(inUseFileExists); + var inUseFiles = Files.walk(vaultPath.resolve("d")).filter( p -> p.getFileName().toString().endsWith(Constants.INUSE_FILE_SUFFIX)).toList(); + Assertions.assertEquals(0, inUseFiles.size()); } //https://github.com/cryptomator/cryptofs/issues/173 diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index fbf15abc..5671ec07 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -1,5 +1,6 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.event.FileIsInUseEvent; import org.cryptomator.cryptofs.event.FilesystemEvent; import org.junit.jupiter.api.Assertions; @@ -136,12 +137,12 @@ public void testTryMarkInUseCreateNew() throws IOException { @Test - @DisplayName("Lock files end with .c9l and are in the same directory as the content file") + @DisplayName("Lock files end with .c9u and are in the same directory as the content file") public void testComputeInUseFilePath(@TempDir Path tmpDir) { var path = tmpDir.resolve("hello.abc"); var result = InUseFile.computeInUseFilePath(path); - Assertions.assertTrue(result.toString().endsWith(".c9l")); + Assertions.assertTrue(result.toString().endsWith(Constants.INUSE_FILE_SUFFIX)); Assertions.assertEquals(path.getParent(), result.getParent()); } @@ -153,7 +154,7 @@ public void testComputeInUseFilePathWithRoot(@TempDir Path tmpDir) { var result = InUseFile.computeInUseFilePath(rootChild); - Assertions.assertTrue(result.toString().endsWith(".c9l")); + Assertions.assertTrue(result.toString().endsWith(Constants.INUSE_FILE_SUFFIX)); Assertions.assertEquals(rootChild.getParent(), result.getParent()); } From 640f7aa811f3d8d2ab446a4853fd1fbd88320713 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 7 Aug 2025 16:20:12 +0200 Subject: [PATCH 13/95] rename method --- .../java/org/cryptomator/cryptofs/fh/InUseFile.java | 4 ++-- .../org/cryptomator/cryptofs/fh/InUseFileTest.java | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index 4fc29a93..b97a1d7f 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -59,7 +59,7 @@ synchronized boolean tryMarkInUse() { createInUseFile(inUseFilePath); } catch (FileTooBigException | IllegalArgumentException e) { LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); - ownInUseFile(inUseFilePath); + stealInUseFile(inUseFilePath); } catch (IOException e) { LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); } @@ -113,7 +113,7 @@ void createInUseFile(Path inUseFilePath) { } } - void ownInUseFile(Path inUseFilePath) { + void stealInUseFile(Path inUseFilePath) { try { this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); //TODO: delete on close? writeInUseInfo(); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index 5671ec07..dc693e60 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -69,7 +69,7 @@ public void testTryMarkInUseReadExistingSameOwner() throws IOException { Assertions.assertFalse(isInUse); verify(inUseFileSpy, never()).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + verify(inUseFileSpy, never()).stealInUseFile(inUsePath); //TODO: check, that inUse file is updated } } @@ -91,7 +91,7 @@ public void testTryMarkInUseReadExistingDifferentOwner() throws IOException { Assertions.assertTrue(isInUse); verify(inUseFileSpy, never()).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + verify(inUseFileSpy, never()).stealInUseFile(inUsePath); var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent; verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); } @@ -102,7 +102,7 @@ public void testTryMarkInUseReadExistingDifferentOwner() throws IOException { public void testTryMarkInUseReadExistingInvalid() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); - doNothing().when(inUseFileSpy).ownInUseFile(inUsePath); + doNothing().when(inUseFileSpy).stealInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(IllegalArgumentException.class); @@ -111,7 +111,7 @@ public void testTryMarkInUseReadExistingInvalid() throws IOException { var isInUse = inUseFileSpy.tryMarkInUse(); Assertions.assertFalse(isInUse); - verify(inUseFileSpy).ownInUseFile(inUsePath); + verify(inUseFileSpy).stealInUseFile(inUsePath); verify(inUseFileSpy, never()).createInUseFile(inUsePath); } } @@ -131,7 +131,7 @@ public void testTryMarkInUseCreateNew() throws IOException { Assertions.assertFalse(isInUse); verify(inUseFileSpy).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).ownInUseFile(inUsePath); + verify(inUseFileSpy, never()).stealInUseFile(inUsePath); } } From 768ce65b7740d9d49d265771e0ce6afaeb68354b Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 7 Aug 2025 16:20:23 +0200 Subject: [PATCH 14/95] add todo --- src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 480561f6..dff4b1e9 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -70,7 +70,7 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil var openChannels = openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { // in-use section - if (openChannels == 1 && inUseFile.tryMarkInUse()) { //add ignore mechanic + if (openChannels == 1 && inUseFile.tryMarkInUse()) { //TODO: add ignore mechanic throw new FileIsInUseException(path); } ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); From eb49e42d3da59d6a462187d0da0e7f5657e988b3 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 7 Aug 2025 18:04:22 +0200 Subject: [PATCH 15/95] add map for selfUsed files --- .../cryptofs/CryptoFileSystemModule.java | 10 +++ .../cryptomator/cryptofs/fh/InUseFile.java | 62 +++++++++++++----- .../cryptofs/fh/InUseFileTest.java | 64 +++++++++++++++++-- 3 files changed, 115 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index 143f465f..7d1207b4 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -7,6 +7,7 @@ import dagger.Module; import dagger.Provides; +import jakarta.inject.Named; import org.cryptomator.cryptofs.attr.AttributeComponent; import org.cryptomator.cryptofs.attr.AttributeViewComponent; import org.cryptomator.cryptofs.dir.DirectoryStreamComponent; @@ -20,6 +21,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.function.Consumer; @Module(subcomponents = {AttributeComponent.class, AttributeViewComponent.class, OpenCryptoFileComponent.class, DirectoryStreamComponent.class}) @@ -50,4 +53,11 @@ public Consumer provideFilesystemEventConsumer(CryptoFileSystem } }; } + + @Provides + @CryptoFileSystemScoped + @Named("selfUsedFiles") + public ConcurrentMap provideSelfUsedFiles() { + return new ConcurrentHashMap<>(); + } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index b97a1d7f..567a6844 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -1,6 +1,7 @@ package org.cryptomator.cryptofs.fh; import jakarta.inject.Inject; +import jakarta.inject.Named; import org.cryptomator.cryptofs.CryptoFileSystemProperties; import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.FileTooBigException; @@ -18,9 +19,12 @@ import java.nio.channels.SeekableByteChannel; import java.nio.file.Files; import java.nio.file.NoSuchFileException; +import java.nio.file.OpenOption; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -30,16 +34,21 @@ public class InUseFile implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(InUseFile.class); private final String fileSystemOwner; + private final ConcurrentMap selfUsedFiles; private final Properties info; private final AtomicReference currentFilePath; private final Consumer eventConsumer; private SeekableByteChannel inUseFileChannel; @Inject - public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, Consumer eventConsumer, CryptoFileSystemProperties fsProps) { + public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, // + Consumer eventConsumer, // + CryptoFileSystemProperties fsProps, // + @Named("selfUsedFiles") ConcurrentMap selfUsedFiles) { this.currentFilePath = currentFilePath; this.eventConsumer = eventConsumer; this.fileSystemOwner = (String) fsProps.getOrDefault("owner", "cryptobot"); + this.selfUsedFiles = selfUsedFiles; this.info = new Properties(); info.put("owner", fileSystemOwner); } @@ -47,25 +56,35 @@ public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, Con synchronized boolean tryMarkInUse() { var ciphertextPath = currentFilePath.get(); var inUseFilePath = computeInUseFilePath(ciphertextPath); + var selfUseSuccessful = false; try { if (isInUse(inUseFilePath, fileSystemOwner)) { eventConsumer.accept(new FileIsInUseEvent(Path.of("yadda"), ciphertextPath, info)); return true; } - //TODO: update timestamps + selfUseSuccessful = updateInUseFile(inUseFilePath); } catch (NoSuchFileException e) { LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); //TODO: delay creation with a CompletionStage (to prevent spam) - createInUseFile(inUseFilePath); + selfUseSuccessful = createInUseFile(inUseFilePath); } catch (FileTooBigException | IllegalArgumentException e) { LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); - stealInUseFile(inUseFilePath); + selfUseSuccessful = stealInUseFile(inUseFilePath); } catch (IOException e) { LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); } + + if (selfUseSuccessful) { + selfUsedFiles.put(ciphertextPath, Boolean.TRUE); + } return false; } + private boolean updateInUseFile(Path inUseFilePath) { + //TODO + return true; + } + /** * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running cryptofile system. * @@ -77,7 +96,7 @@ synchronized boolean tryMarkInUse() { */ public static boolean isInUse(Path inUseFilePath, String fileSystemOwner) throws IOException, IllegalArgumentException { Properties content = readInUseFile(inUseFilePath); - if(!content.get("owner").equals(fileSystemOwner)) { + if (!content.get("owner").equals(fileSystemOwner)) { //TODO: check also timestamps return true; } @@ -104,29 +123,32 @@ private static void validate(Properties content) throws IllegalArgumentException //TODO: more keys } - void createInUseFile(Path inUseFilePath) { + boolean createInUseFile(Path inUseFilePath) { try { - this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); //TODO: delete on close? - writeInUseInfo(); + return writeInUseFile(inUseFilePath, Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)); } catch (IOException e) { LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + return false; } } - void stealInUseFile(Path inUseFilePath) { + boolean stealInUseFile(Path inUseFilePath) { try { - this.inUseFileChannel = Files.newByteChannel(inUseFilePath, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); //TODO: delete on close? - writeInUseInfo(); + return writeInUseFile(inUseFilePath, Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)); } catch (IOException e) { - LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + LOG.warn("Failed to steal in-use file for {}.", inUseFilePath, e); + return false; } } - void writeInUseInfo() throws IOException { + boolean writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { + this.inUseFileChannel = Files.newByteChannel(inUseFilePath, openOptions); //TODO: delete on close? var rawInfo = new ByteArrayOutputStream(4_000); info.store(rawInfo, "UNENCRYPTED Cryptomator inUse file"); //TODO: encryption - this.inUseFileChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); + inUseFileChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); + inUseFileChannel.position(0); + return true; } //for testing @@ -137,11 +159,13 @@ void deleteInUseFile(Path inUseFilePath) throws IOException { @Override public synchronized void close() { + var ciphertextPath = currentFilePath.get(); + selfUsedFiles.remove(ciphertextPath); if (inUseFileChannel != null) { try { //delay closing with a completionStage inUseFileChannel.close(); - var inUsePath = computeInUseFilePath(currentFilePath.get()); + var inUsePath = computeInUseFilePath(ciphertextPath); deleteInUseFile(inUsePath); } catch (IOException e) { LOG.error("Unable to delete in-use-file. Must be cleaned manually."); @@ -161,11 +185,17 @@ public static Path computeInUseFilePath(Path p) { //-- for testing only - InUseFile(AtomicReference currentFilePath, Consumer eventConsumer, String fileSystemOwner, SeekableByteChannel inUseFileChannel, Properties info) { + InUseFile(AtomicReference currentFilePath, // + Consumer eventConsumer, // + String fileSystemOwner, // + SeekableByteChannel inUseFileChannel, // + Properties info, // + ConcurrentMap selfUsedFiles) { this.currentFilePath = currentFilePath; this.eventConsumer = eventConsumer; this.fileSystemOwner = fileSystemOwner; this.inUseFileChannel = inUseFileChannel; this.info = info; + this.selfUsedFiles = selfUsedFiles; } } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index dc693e60..0ea3ec25 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -16,17 +16,21 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.Properties; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; public class InUseFileTest { @@ -42,15 +46,18 @@ public class InUseFileTest { * getInUseFilePath */ + Path ciphertextPath = mock(Path.class, "ciphertextPath"); AtomicReference currentFilePath = new AtomicReference<>(); Consumer eventConsumer = mock(Consumer.class); SeekableByteChannel inUseChannel = mock(SeekableByteChannel.class); + ConcurrentMap selfUsedFiles = mock(ConcurrentMap.class); Properties info = new Properties(); InUseFile inUseFile; @BeforeEach public void beforeEach() { - inUseFile = new InUseFile(currentFilePath, eventConsumer, "cryptobot", inUseChannel, info); + currentFilePath.set(ciphertextPath); + inUseFile = new InUseFile(currentFilePath, eventConsumer, "cryptobot", inUseChannel, info, selfUsedFiles); } @Test @@ -70,6 +77,7 @@ public void testTryMarkInUseReadExistingSameOwner() throws IOException { Assertions.assertFalse(isInUse); verify(inUseFileSpy, never()).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); + verify(selfUsedFiles).put(ciphertextPath, true); //TODO: check, that inUse file is updated } } @@ -94,15 +102,16 @@ public void testTryMarkInUseReadExistingDifferentOwner() throws IOException { verify(inUseFileSpy, never()).stealInUseFile(inUsePath); var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent; verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); + verify(selfUsedFiles, never()).put(any(), anyBoolean()); } } @Test - @DisplayName("CheckOrOwn for existing, invalid inUseFile owns it") + @DisplayName("CheckOrOwn for existing, invalid inUseFile steals it") public void testTryMarkInUseReadExistingInvalid() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); - doNothing().when(inUseFileSpy).stealInUseFile(inUsePath); + doReturn(true).when(inUseFileSpy).stealInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(IllegalArgumentException.class); @@ -113,15 +122,56 @@ public void testTryMarkInUseReadExistingInvalid() throws IOException { Assertions.assertFalse(isInUse); verify(inUseFileSpy).stealInUseFile(inUsePath); verify(inUseFileSpy, never()).createInUseFile(inUsePath); + verify(selfUsedFiles).put(ciphertextPath, true); } } @Test - @DisplayName("CheckOrOwn creates inUseFile if it does not exist") + @DisplayName("tryMarkInUse for existing, invalid inUseFile with failing steal does nothing") + public void testTryMarkInUseReadExistingInvalidFailedSteal() throws IOException { + var inUseFileSpy = spy(inUseFile); + Path inUsePath = mock(Path.class, "inUseFilePath"); + doReturn(false).when(inUseFileSpy).stealInUseFile(inUsePath); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); + classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(IllegalArgumentException.class); + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + + var isInUse = inUseFileSpy.tryMarkInUse(); + + Assertions.assertFalse(isInUse); + verify(inUseFileSpy).stealInUseFile(inUsePath); + verify(inUseFileSpy, never()).createInUseFile(inUsePath); + verify(selfUsedFiles, never()).put(any(), anyBoolean()); + } + } + + @Test + @DisplayName("tryMarkInUse creates inUseFile if it does not exist") public void testTryMarkInUseCreateNew() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); - doNothing().when(inUseFileSpy).createInUseFile(inUsePath); + doReturn(true).when(inUseFileSpy).createInUseFile(inUsePath); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); + classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(NoSuchFileException.class); + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + + var isInUse = inUseFileSpy.tryMarkInUse(); + + Assertions.assertFalse(isInUse); + verify(inUseFileSpy).createInUseFile(inUsePath); + verify(inUseFileSpy, never()).stealInUseFile(inUsePath); + verify(selfUsedFiles).put(ciphertextPath, true); + } + } + + @Test + @DisplayName("tryMarkInUse with failing create inUseFile") + public void testTryMarkInUseCreateNewFailing() throws IOException { + var inUseFileSpy = spy(inUseFile); + Path inUsePath = mock(Path.class, "inUseFilePath"); + doReturn(false).when(inUseFileSpy).createInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(NoSuchFileException.class); @@ -132,6 +182,7 @@ public void testTryMarkInUseCreateNew() throws IOException { Assertions.assertFalse(isInUse); verify(inUseFileSpy).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); + verify(selfUsedFiles, never()).put(any(), anyBoolean()); } } @@ -169,6 +220,7 @@ public void testClose() throws IOException { inUseFileSpy.close(); + verify(selfUsedFiles).remove(ciphertextPath); verify(inUseChannel).close(); verify(inUseFileSpy).deleteInUseFile(inUsePath); } @@ -184,9 +236,11 @@ public void testCloseFailing() throws IOException { doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); Assertions.assertDoesNotThrow(inUseFileSpy::close); + verify(selfUsedFiles).remove(ciphertextPath); doThrow(IOException.class).when(inUseChannel).close(); Assertions.assertDoesNotThrow(inUseFileSpy::close); + verify(selfUsedFiles, times(2)).remove(ciphertextPath); } } From 880f0b8d68f1f691c855ddbd34a7ad5401b02343 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 8 Aug 2025 17:46:28 +0200 Subject: [PATCH 16/95] refactor and rename classes * rename FileIsInUseException to FileAlreadyInUseException * rename InUse:tryMarkInUse to acquire() * add ignore flag for inUse check --- .idea/codeStyles/Project.xml | 1 + .idea/misc.xml | 2 +- .../cryptofs/CryptoFileSystemImpl.java | 2 +- ...on.java => FileAlreadyInUseException.java} | 4 +- .../cryptomator/cryptofs/fh/InUseFile.java | 12 +-- .../cryptofs/fh/OpenCryptoFile.java | 8 +- .../cryptofs/fh/OpenCryptoFiles.java | 8 +- .../cryptofs/CryptoFileSystemImplTest.java | 6 +- .../cryptofs/fh/InUseFileTest.java | 85 ++++++++----------- .../cryptofs/fh/OpenCryptoFileTest.java | 50 +++++++---- .../cryptofs/fh/OpenCryptoFilesTest.java | 3 +- 11 files changed, 96 insertions(+), 85 deletions(-) rename src/main/java/org/cryptomator/cryptofs/fh/{FileIsInUseException.java => FileAlreadyInUseException.java} (54%) diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index d361191e..82b05994 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -16,6 +16,7 @@ - + \ No newline at end of file diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 783e78e6..54016b33 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -402,7 +402,7 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti Files.createDirectories(ciphertextPath.getRawPath()); // suppresses FileAlreadyExists } - FileChannel ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options, attrs); // might throw FileAlreadyExists + FileChannel ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options,false, attrs); // might throw FileAlreadyExists try { if (options.writable()) { ciphertextPath.persistLongFileName(); diff --git a/src/main/java/org/cryptomator/cryptofs/fh/FileIsInUseException.java b/src/main/java/org/cryptomator/cryptofs/fh/FileAlreadyInUseException.java similarity index 54% rename from src/main/java/org/cryptomator/cryptofs/fh/FileIsInUseException.java rename to src/main/java/org/cryptomator/cryptofs/fh/FileAlreadyInUseException.java index 4f588f6b..c5ff3d83 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/FileIsInUseException.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/FileAlreadyInUseException.java @@ -3,9 +3,9 @@ import java.nio.file.FileSystemException; import java.nio.file.Path; -public class FileIsInUseException extends FileSystemException { +public class FileAlreadyInUseException extends FileSystemException { - public FileIsInUseException(Path path) { + public FileAlreadyInUseException(Path path) { super(path.toString()); } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index 567a6844..f6a0060b 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -53,16 +53,18 @@ public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, // info.put("owner", fileSystemOwner); } - synchronized boolean tryMarkInUse() { + synchronized boolean acquire() throws FileAlreadyInUseException { var ciphertextPath = currentFilePath.get(); var inUseFilePath = computeInUseFilePath(ciphertextPath); var selfUseSuccessful = false; try { if (isInUse(inUseFilePath, fileSystemOwner)) { - eventConsumer.accept(new FileIsInUseEvent(Path.of("yadda"), ciphertextPath, info)); - return true; + eventConsumer.accept(new FileIsInUseEvent(Path.of("dummyCleartextPath"), ciphertextPath, info)); + throw new FileAlreadyInUseException(ciphertextPath); } selfUseSuccessful = updateInUseFile(inUseFilePath); + } catch (FileAlreadyInUseException e) { + throw e; } catch (NoSuchFileException e) { LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); //TODO: delay creation with a CompletionStage (to prevent spam) @@ -77,7 +79,7 @@ synchronized boolean tryMarkInUse() { if (selfUseSuccessful) { selfUsedFiles.put(ciphertextPath, Boolean.TRUE); } - return false; + return selfUseSuccessful; } private boolean updateInUseFile(Path inUseFilePath) { @@ -94,7 +96,7 @@ private boolean updateInUseFile(Path inUseFilePath) { * @throws IOException if the in-use-file does not exist or cannot be read * @throws IllegalArgumentException if the in-use-file is invalid */ - public static boolean isInUse(Path inUseFilePath, String fileSystemOwner) throws IOException, IllegalArgumentException { + static boolean isInUse(Path inUseFilePath, String fileSystemOwner) throws IOException, IllegalArgumentException { Properties content = readInUseFile(inUseFilePath); if (!content.get("owner").equals(fileSystemOwner)) { //TODO: check also timestamps diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index dff4b1e9..23615847 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -59,7 +59,7 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol * @return A new file channel. Ideally used in a try-with-resource statement. If the channel is not properly closed, this OpenCryptoFile will stay open indefinite. * @throws IOException */ - public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, FileAttribute... attrs) throws IOException { + public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, boolean skipUsageCheck, FileAttribute... attrs) throws IOException { Path path = currentFilePath.get(); if (path == null) { throw new IllegalStateException("Cannot create file channel to deleted file"); @@ -69,9 +69,9 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil var openChannels = openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { - // in-use section - if (openChannels == 1 && inUseFile.tryMarkInUse()) { //TODO: add ignore mechanic - throw new FileIsInUseException(path); + //TODO: what about read-only file channels? Then we need to update logic, that first writable channel needs to create this file + if (openChannels == 1 && !skipUsageCheck) { + inUseFile.acquire(); } ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); initFileHeader(options, ciphertextFileChannel); diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java index 1ecc13b5..46220be2 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java @@ -8,10 +8,10 @@ *******************************************************************************/ package org.cryptomator.cryptofs.fh; +import jakarta.inject.Inject; import org.cryptomator.cryptofs.CryptoFileSystemScoped; import org.cryptomator.cryptofs.EffectiveOpenOptions; -import jakarta.inject.Inject; import java.io.Closeable; import java.io.IOException; import java.nio.BufferUnderflowException; @@ -51,7 +51,7 @@ 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. + * Opens a file to {@link OpenCryptoFile#newFileChannel(EffectiveOpenOptions, boolean, 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 @@ -64,13 +64,13 @@ public OpenCryptoFile getOrCreate(Path ciphertextPath) { } public void writeCiphertextFile(Path ciphertextPath, EffectiveOpenOptions openOptions, ByteBuffer contents) throws IOException { - try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { + try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions, false)) { //TODO: test 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)) { + try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions, false)) { if (ch.size() > maxBufferSize) { throw new BufferUnderflowException(); } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 5343dfc7..1e831e8a 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -71,6 +71,8 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -455,7 +457,7 @@ public void setup() throws IOException { when(ciphertextPath.getFilePath()).thenReturn(ciphertextFilePath); when(openCryptoFiles.getOrCreate(ciphertextFilePath)).thenReturn(openCryptoFile); when(ciphertextFilePath.getName(3)).thenReturn(mock(CryptoPath.class, "path.c9r")); - when(openCryptoFile.newFileChannel(any(), any(FileAttribute[].class))).thenReturn(fileChannel); + when(openCryptoFile.newFileChannel(any(), anyBoolean(), any(FileAttribute[].class))).thenReturn(fileChannel); } @Nested @@ -509,7 +511,7 @@ public void testNewFileChannelCreate3() throws IOException { FileChannel ch = inTest.newFileChannel(cleartextPath, EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), attrs); Assertions.assertSame(fileChannel, ch); - verify(openCryptoFile).newFileChannel(Mockito.any(), Mockito.eq(attrs)); + verify(openCryptoFile).newFileChannel(Mockito.any(), eq(false), Mockito.eq(attrs)); } } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index 0ea3ec25..2a6eab5f 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentMatcher; import org.mockito.ArgumentMatchers; @@ -23,21 +24,13 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; public class InUseFileTest { /* To test: - * checkOrOwn + * acquire * readInUseFile * createInUseFile * writeInUseFile @@ -61,20 +54,18 @@ public void beforeEach() { } @Test - @DisplayName("CheckOrOwn for existing, valid inUseFile with same owner") - public void testTryMarkInUseReadExistingSameOwner() throws IOException { + @DisplayName("Acquiring existing, valid inUseFile") + public void testAcquireExistingSameOwner() throws IOException { var inUseFileSpy = spy(inUseFile); - var inUseInfo = new Properties(); - inUseInfo.put("owner", "cryptobot"); Path inUsePath = mock(Path.class, "inUseFilePath"); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenReturn(inUseInfo); + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenReturn(false); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - var isInUse = inUseFileSpy.tryMarkInUse(); + var isInUse = inUseFileSpy.acquire(); - Assertions.assertFalse(isInUse); + Assertions.assertTrue(isInUse); verify(inUseFileSpy, never()).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); verify(selfUsedFiles).put(ciphertextPath, true); @@ -83,21 +74,19 @@ public void testTryMarkInUseReadExistingSameOwner() throws IOException { } @Test - @DisplayName("CheckOrOwn for existing, valid inUseFile with different owner") - public void testTryMarkInUseReadExistingDifferentOwner() throws IOException { + @DisplayName("Acquiring existing, valid inUseFile with different owner throws exception") + public void testAcquireExistingDifferentOwnerThrows() throws IOException { var inUseFileSpy = spy(inUseFile); - var inUseInfo = new Properties(); - inUseInfo.put("owner", "cryptobot3000"); Path inUsePath = mock(Path.class, "inUseFilePath"); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenReturn(inUseInfo); - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenReturn(true); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - var isInUse = inUseFileSpy.tryMarkInUse(); + Executable test = () -> inUseFile.acquire(); + + Assertions.assertThrows(FileAlreadyInUseException.class, test); - Assertions.assertTrue(isInUse); verify(inUseFileSpy, never()).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent; @@ -107,19 +96,18 @@ public void testTryMarkInUseReadExistingDifferentOwner() throws IOException { } @Test - @DisplayName("CheckOrOwn for existing, invalid inUseFile steals it") - public void testTryMarkInUseReadExistingInvalid() throws IOException { + @DisplayName("Acquire existing, invalid inUseFile steals it") + public void testAcquireReadExistingInvalid() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(true).when(inUseFileSpy).stealInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); - classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(IllegalArgumentException.class); + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenThrow(IllegalArgumentException.class); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - var isInUse = inUseFileSpy.tryMarkInUse(); + var isAcquired = inUseFileSpy.acquire(); - Assertions.assertFalse(isInUse); + Assertions.assertTrue(isAcquired); verify(inUseFileSpy).stealInUseFile(inUsePath); verify(inUseFileSpy, never()).createInUseFile(inUsePath); verify(selfUsedFiles).put(ciphertextPath, true); @@ -127,19 +115,19 @@ public void testTryMarkInUseReadExistingInvalid() throws IOException { } @Test - @DisplayName("tryMarkInUse for existing, invalid inUseFile with failing steal does nothing") - public void testTryMarkInUseReadExistingInvalidFailedSteal() throws IOException { + @DisplayName("Acquire existing, invalid inUseFile with failing steal") + public void testAcquireReadExistingInvalidFailedSteal() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(false).when(inUseFileSpy).stealInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); - classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(IllegalArgumentException.class); + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenThrow(IllegalArgumentException.class); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + when(inUseFileSpy.stealInUseFile(inUsePath)).thenReturn(false); - var isInUse = inUseFileSpy.tryMarkInUse(); + var isAcquired = inUseFileSpy.acquire(); - Assertions.assertFalse(isInUse); + Assertions.assertFalse(isAcquired); verify(inUseFileSpy).stealInUseFile(inUsePath); verify(inUseFileSpy, never()).createInUseFile(inUsePath); verify(selfUsedFiles, never()).put(any(), anyBoolean()); @@ -147,19 +135,18 @@ public void testTryMarkInUseReadExistingInvalidFailedSteal() throws IOException } @Test - @DisplayName("tryMarkInUse creates inUseFile if it does not exist") - public void testTryMarkInUseCreateNew() throws IOException { + @DisplayName("Acquire not existing inUseFile creates it") + public void testAcquireCreateNew() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(true).when(inUseFileSpy).createInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); - classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(NoSuchFileException.class); + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenThrow(NoSuchFileException.class); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - var isInUse = inUseFileSpy.tryMarkInUse(); + var isAcquired = inUseFileSpy.acquire(); - Assertions.assertFalse(isInUse); + Assertions.assertTrue(isAcquired); verify(inUseFileSpy).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); verify(selfUsedFiles).put(ciphertextPath, true); @@ -167,19 +154,19 @@ public void testTryMarkInUseCreateNew() throws IOException { } @Test - @DisplayName("tryMarkInUse with failing create inUseFile") - public void testTryMarkInUseCreateNewFailing() throws IOException { + @DisplayName("Acquire not existing inUseFile with failing create") + public void testAcquireCreateNewFailing() throws IOException { var inUseFileSpy = spy(inUseFile); Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(false).when(inUseFileSpy).createInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenCallRealMethod(); - classMock.when(() -> InUseFile.readInUseFile(inUsePath)).thenThrow(NoSuchFileException.class); + classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenThrow(NoSuchFileException.class); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + when(inUseFileSpy.createInUseFile(inUsePath)).thenReturn(false); - var isInUse = inUseFileSpy.tryMarkInUse(); + var isAcquired = inUseFileSpy.acquire(); - Assertions.assertFalse(isInUse); + Assertions.assertFalse(isAcquired); verify(inUseFileSpy).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); verify(selfUsedFiles, never()).put(any(), anyBoolean()); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index 467451a2..daf27911 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -83,29 +83,46 @@ public void testClose() { // tests https://github.com/cryptomator/cryptofs/issues/51 @Test @DisplayName("if the first file channel fails to open, call OpenCryptoFile::close") - public void testFailedFirstFileChannelImmediatelyCallsClose() { + public void testFailedFirstFileChannelImmediatelyCallsClose() throws FileAlreadyInUseException { UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - when(inUseFile.tryMarkInUse()).thenReturn(false); + when(inUseFile.acquire()).thenReturn(true); OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { - openCryptoFile.newFileChannel(options); + openCryptoFile.newFileChannel(options, false); }); Assertions.assertSame(expectedException, exception); verify(openCryptoFile).close(); } + @Test + @DisplayName("skip inUseFile, if flag is set") + public void testSkipInUseCheck() throws FileAlreadyInUseException { + UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); + EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); + Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); + when(inUseFile.acquire()).thenThrow(FileAlreadyInUseException.class); + OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); + + UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { + openCryptoFile.newFileChannel(options, true); + }); + Assertions.assertSame(expectedException, exception); + verify(openCryptoFile).close(); + verify(inUseFile, never()).acquire(); + } + @Test @DisplayName("if the file is in use, throw exception") - public void testInUseFileThrowsException() { + public void testInUseFileThrowsException() throws FileAlreadyInUseException { EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); - when(inUseFile.tryMarkInUse()).thenReturn(true); + when(inUseFile.acquire()).thenThrow(FileAlreadyInUseException.class); OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); - Assertions.assertThrows(FileIsInUseException.class, () -> { - openCryptoFile.newFileChannel(options); + Assertions.assertThrows(FileAlreadyInUseException.class, () -> { + openCryptoFile.newFileChannel(options, false); }); } @@ -124,19 +141,20 @@ public void testFailedSecondFileChannelDoesNothing() throws IOException { Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); - when(inUseFile.tryMarkInUse()).thenReturn(false); + when(inUseFile.acquire()).thenReturn(false); EffectiveOpenOptions failingOptions = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(failingOptions.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); - try (var channel = openCryptoFile.newFileChannel(options)) { + try (var channel = openCryptoFile.newFileChannel(options, false)) { UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { - openCryptoFile.newFileChannel(failingOptions); + openCryptoFile.newFileChannel(failingOptions, false); }); Assertions.assertSame(expectedException, exception); verify(openCryptoFile, never()).close(); } + verify(inUseFile, times(1)).acquire(); } @Test @@ -152,9 +170,9 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); - when(inUseFile.tryMarkInUse()).thenReturn(false); + when(inUseFile.acquire()).thenReturn(true); - openCryptoFile.newFileChannel(options); + openCryptoFile.newFileChannel(options, false); verify(cleartextChannel).truncate(0L); } @@ -275,11 +293,11 @@ public void testGetSizeBeforeCreatingFileChannel() { public void createFileChannel() throws IOException { var attrs = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); - when(inUseFile.tryMarkInUse()).thenReturn(false); - FileChannel ch = openCryptoFile.newFileChannel(options, attrs); + when(inUseFile.acquire()).thenReturn(false); + FileChannel ch = openCryptoFile.newFileChannel(options, false, attrs); Assertions.assertSame(cleartextFileChannel, ch); verify(chunkIO).registerChannel(ciphertextChannel.get(), true); - verify(inUseFile).tryMarkInUse(); + verify(inUseFile).acquire(); } @Test @@ -299,7 +317,7 @@ public void errorDuringCreationOfSecondChannel() { Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { - openCryptoFile.newFileChannel(options); + openCryptoFile.newFileChannel(options, false); }); Assertions.assertSame(expectedException, exception); verify(closeListener, Mockito.never()).close(CURRENT_FILE_PATH.get(), openCryptoFile); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java index fa01dbfb..09297619 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java @@ -16,6 +16,7 @@ import java.nio.file.Path; import java.nio.file.Paths; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.mock; public class OpenCryptoFilesTest { @@ -32,7 +33,7 @@ public void setup() throws IOException, ReflectiveOperationException { Mockito.when(subComponent.openCryptoFile()).thenReturn(file); Mockito.when(openCryptoFileComponentFactory.create(Mockito.any(), Mockito.any())).thenReturn(subComponent); - Mockito.when(file.newFileChannel(Mockito.any())).thenReturn(ciphertextFileChannel); + Mockito.when(file.newFileChannel(Mockito.any(), anyBoolean())).thenReturn(ciphertextFileChannel); inTest = new OpenCryptoFiles(openCryptoFileComponentFactory); } From 40cb2075116095c4e7da5129dfae7cb97045b7cc Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 12 Aug 2025 10:52:43 +0200 Subject: [PATCH 17/95] remove unused inUseFile map --- .../cryptofs/CryptoFileSystemModule.java | 10 ---------- .../cryptomator/cryptofs/fh/InUseFile.java | 20 +++++++------------ .../cryptofs/fh/InUseFileTest.java | 12 +---------- 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index 7d1207b4..143f465f 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -7,7 +7,6 @@ import dagger.Module; import dagger.Provides; -import jakarta.inject.Named; import org.cryptomator.cryptofs.attr.AttributeComponent; import org.cryptomator.cryptofs.attr.AttributeViewComponent; import org.cryptomator.cryptofs.dir.DirectoryStreamComponent; @@ -21,8 +20,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import java.util.function.Consumer; @Module(subcomponents = {AttributeComponent.class, AttributeViewComponent.class, OpenCryptoFileComponent.class, DirectoryStreamComponent.class}) @@ -53,11 +50,4 @@ public Consumer provideFilesystemEventConsumer(CryptoFileSystem } }; } - - @Provides - @CryptoFileSystemScoped - @Named("selfUsedFiles") - public ConcurrentMap provideSelfUsedFiles() { - return new ConcurrentHashMap<>(); - } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index f6a0060b..e83dc18b 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -1,7 +1,6 @@ package org.cryptomator.cryptofs.fh; import jakarta.inject.Inject; -import jakarta.inject.Named; import org.cryptomator.cryptofs.CryptoFileSystemProperties; import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.FileTooBigException; @@ -24,7 +23,6 @@ import java.nio.file.StandardOpenOption; import java.util.Properties; import java.util.Set; -import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -34,21 +32,20 @@ public class InUseFile implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(InUseFile.class); private final String fileSystemOwner; - private final ConcurrentMap selfUsedFiles; private final Properties info; private final AtomicReference currentFilePath; private final Consumer eventConsumer; private SeekableByteChannel inUseFileChannel; + private volatile Path lastKnownPath; + @Inject public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, // Consumer eventConsumer, // - CryptoFileSystemProperties fsProps, // - @Named("selfUsedFiles") ConcurrentMap selfUsedFiles) { + CryptoFileSystemProperties fsProps) { this.currentFilePath = currentFilePath; this.eventConsumer = eventConsumer; this.fileSystemOwner = (String) fsProps.getOrDefault("owner", "cryptobot"); - this.selfUsedFiles = selfUsedFiles; this.info = new Properties(); info.put("owner", fileSystemOwner); } @@ -77,7 +74,7 @@ synchronized boolean acquire() throws FileAlreadyInUseException { } if (selfUseSuccessful) { - selfUsedFiles.put(ciphertextPath, Boolean.TRUE); + lastKnownPath = inUseFilePath; //TODO: to prevent two files point to the same file (and might delete the owner file on close, we need a map) } return selfUseSuccessful; } @@ -162,15 +159,14 @@ void deleteInUseFile(Path inUseFilePath) throws IOException { @Override public synchronized void close() { var ciphertextPath = currentFilePath.get(); - selfUsedFiles.remove(ciphertextPath); if (inUseFileChannel != null) { try { //delay closing with a completionStage inUseFileChannel.close(); - var inUsePath = computeInUseFilePath(ciphertextPath); + var inUsePath = ciphertextPath != null? computeInUseFilePath(ciphertextPath) : lastKnownPath; //currentFilePath takes precedence deleteInUseFile(inUsePath); } catch (IOException e) { - LOG.error("Unable to delete in-use-file. Must be cleaned manually."); + LOG.warn("Unable to delete in-use-file. Must be cleaned manually."); } } } @@ -191,13 +187,11 @@ public static Path computeInUseFilePath(Path p) { Consumer eventConsumer, // String fileSystemOwner, // SeekableByteChannel inUseFileChannel, // - Properties info, // - ConcurrentMap selfUsedFiles) { + Properties info) { this.currentFilePath = currentFilePath; this.eventConsumer = eventConsumer; this.fileSystemOwner = fileSystemOwner; this.inUseFileChannel = inUseFileChannel; this.info = info; - this.selfUsedFiles = selfUsedFiles; } } diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index 2a6eab5f..55f22d3b 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -43,14 +43,13 @@ public class InUseFileTest { AtomicReference currentFilePath = new AtomicReference<>(); Consumer eventConsumer = mock(Consumer.class); SeekableByteChannel inUseChannel = mock(SeekableByteChannel.class); - ConcurrentMap selfUsedFiles = mock(ConcurrentMap.class); Properties info = new Properties(); InUseFile inUseFile; @BeforeEach public void beforeEach() { currentFilePath.set(ciphertextPath); - inUseFile = new InUseFile(currentFilePath, eventConsumer, "cryptobot", inUseChannel, info, selfUsedFiles); + inUseFile = new InUseFile(currentFilePath, eventConsumer, "cryptobot", inUseChannel, info); } @Test @@ -68,7 +67,6 @@ public void testAcquireExistingSameOwner() throws IOException { Assertions.assertTrue(isInUse); verify(inUseFileSpy, never()).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); - verify(selfUsedFiles).put(ciphertextPath, true); //TODO: check, that inUse file is updated } } @@ -91,7 +89,6 @@ public void testAcquireExistingDifferentOwnerThrows() throws IOException { verify(inUseFileSpy, never()).stealInUseFile(inUsePath); var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent; verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); - verify(selfUsedFiles, never()).put(any(), anyBoolean()); } } @@ -110,7 +107,6 @@ public void testAcquireReadExistingInvalid() throws IOException { Assertions.assertTrue(isAcquired); verify(inUseFileSpy).stealInUseFile(inUsePath); verify(inUseFileSpy, never()).createInUseFile(inUsePath); - verify(selfUsedFiles).put(ciphertextPath, true); } } @@ -130,7 +126,6 @@ public void testAcquireReadExistingInvalidFailedSteal() throws IOException { Assertions.assertFalse(isAcquired); verify(inUseFileSpy).stealInUseFile(inUsePath); verify(inUseFileSpy, never()).createInUseFile(inUsePath); - verify(selfUsedFiles, never()).put(any(), anyBoolean()); } } @@ -149,7 +144,6 @@ public void testAcquireCreateNew() throws IOException { Assertions.assertTrue(isAcquired); verify(inUseFileSpy).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); - verify(selfUsedFiles).put(ciphertextPath, true); } } @@ -169,7 +163,6 @@ public void testAcquireCreateNewFailing() throws IOException { Assertions.assertFalse(isAcquired); verify(inUseFileSpy).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); - verify(selfUsedFiles, never()).put(any(), anyBoolean()); } } @@ -207,7 +200,6 @@ public void testClose() throws IOException { inUseFileSpy.close(); - verify(selfUsedFiles).remove(ciphertextPath); verify(inUseChannel).close(); verify(inUseFileSpy).deleteInUseFile(inUsePath); } @@ -223,11 +215,9 @@ public void testCloseFailing() throws IOException { doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); Assertions.assertDoesNotThrow(inUseFileSpy::close); - verify(selfUsedFiles).remove(ciphertextPath); doThrow(IOException.class).when(inUseChannel).close(); Assertions.assertDoesNotThrow(inUseFileSpy::close); - verify(selfUsedFiles, times(2)).remove(ciphertextPath); } } From 2a29ded9ccf5f474a35814e2455b618f3857be19 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 12 Aug 2025 15:14:58 +0200 Subject: [PATCH 18/95] move generation of in-use-event to CryptoFileSystemImpl --- .../cryptofs/CryptoFileSystemImpl.java | 20 ++++- .../cryptomator/cryptofs/fh/InUseFile.java | 35 ++++---- .../cryptofs/CryptoFileSystemImplTest.java | 21 ++++- .../cryptofs/fh/InUseFileTest.java | 87 ++++++++++++++----- 4 files changed, 119 insertions(+), 44 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 54016b33..a6d5e573 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -19,6 +19,10 @@ import org.cryptomator.cryptofs.dir.CiphertextDirectoryDeleter; import org.cryptomator.cryptofs.dir.DirectoryStreamFactory; import org.cryptomator.cryptofs.dir.DirectoryStreamFilters; +import org.cryptomator.cryptofs.event.FileIsInUseEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; +import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptofs.fh.InUseFile; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptolib.api.Cryptor; @@ -61,7 +65,9 @@ import java.util.EnumSet; import java.util.Map; import java.util.Optional; +import java.util.Properties; import java.util.Set; +import java.util.function.Consumer; import java.util.stream.Collectors; import static java.lang.String.format; @@ -96,6 +102,7 @@ class CryptoFileSystemImpl extends CryptoFileSystem { private final CryptoPath rootPath; private final CryptoPath emptyPath; private final FileNameDecryptor fileNameDecryptor; + private final Consumer eventConsumer; private volatile boolean open = true; @@ -105,7 +112,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider, DirectoryIdBackup dirIdBackup, // AttributeProvider fileAttributeProvider, AttributeByNameProvider fileAttributeByNameProvider, AttributeViewProvider fileAttributeViewProvider, // OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag, // - CryptoFileSystemProperties fileSystemProperties, FileNameDecryptor fileNameDecryptor) { + CryptoFileSystemProperties fileSystemProperties, FileNameDecryptor fileNameDecryptor, Consumer eventConsumer) { this.provider = provider; this.cryptoFileSystems = cryptoFileSystems; this.pathToVault = pathToVault; @@ -131,6 +138,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems this.rootPath = cryptoPathFactory.rootFor(this); this.emptyPath = cryptoPathFactory.emptyFor(this); this.fileNameDecryptor = fileNameDecryptor; + this.eventConsumer = eventConsumer; } @Override @@ -402,8 +410,9 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti Files.createDirectories(ciphertextPath.getRawPath()); // suppresses FileAlreadyExists } - FileChannel ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options,false, attrs); // might throw FileAlreadyExists + FileChannel ch = null; try { + ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options,false, attrs); // might throw FileAlreadyExists if (options.writable()) { ciphertextPath.persistLongFileName(); stats.incrementAccessesWritten(); @@ -414,7 +423,12 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti stats.incrementAccesses(); return ch; } catch (Exception e) { - ch.close(); + if (e instanceof FileAlreadyInUseException) { + eventConsumer.accept(new FileIsInUseEvent(cleartextFilePath, ciphertextFilePath, new Properties())); //TODO: properties? + } + if(ch != null) { + ch.close(); + } throw e; } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index e83dc18b..07c98389 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -5,8 +5,6 @@ import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.FileTooBigException; import org.cryptomator.cryptofs.common.FileUtil; -import org.cryptomator.cryptofs.event.FileIsInUseEvent; -import org.cryptomator.cryptofs.event.FilesystemEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,7 +22,6 @@ import java.util.Properties; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; @OpenFileScoped public class InUseFile implements Closeable { @@ -34,17 +31,14 @@ public class InUseFile implements Closeable { private final String fileSystemOwner; private final Properties info; private final AtomicReference currentFilePath; - private final Consumer eventConsumer; private SeekableByteChannel inUseFileChannel; - private volatile Path lastKnownPath; + private volatile boolean closed; @Inject public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, // - Consumer eventConsumer, // CryptoFileSystemProperties fsProps) { this.currentFilePath = currentFilePath; - this.eventConsumer = eventConsumer; this.fileSystemOwner = (String) fsProps.getOrDefault("owner", "cryptobot"); this.info = new Properties(); info.put("owner", fileSystemOwner); @@ -56,7 +50,6 @@ synchronized boolean acquire() throws FileAlreadyInUseException { var selfUseSuccessful = false; try { if (isInUse(inUseFilePath, fileSystemOwner)) { - eventConsumer.accept(new FileIsInUseEvent(Path.of("dummyCleartextPath"), ciphertextPath, info)); throw new FileAlreadyInUseException(ciphertextPath); } selfUseSuccessful = updateInUseFile(inUseFilePath); @@ -72,10 +65,6 @@ synchronized boolean acquire() throws FileAlreadyInUseException { } catch (IOException e) { LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); } - - if (selfUseSuccessful) { - lastKnownPath = inUseFilePath; //TODO: to prevent two files point to the same file (and might delete the owner file on close, we need a map) - } return selfUseSuccessful; } @@ -158,16 +147,28 @@ void deleteInUseFile(Path inUseFilePath) throws IOException { @Override public synchronized void close() { + if (closed) { + return; + } + + //always close + this.closed = true; var ciphertextPath = currentFilePath.get(); if (inUseFileChannel != null) { try { - //delay closing with a completionStage inUseFileChannel.close(); - var inUsePath = ciphertextPath != null? computeInUseFilePath(ciphertextPath) : lastKnownPath; //currentFilePath takes precedence - deleteInUseFile(inUsePath); } catch (IOException e) { - LOG.warn("Unable to delete in-use-file. Must be cleaned manually."); + LOG.warn("Unable to close in-use-file for {}. Must be cleaned manually.", ciphertextPath); + } + if (ciphertextPath != null) { + var inUsePath = computeInUseFilePath(currentFilePath.get()); + try { + deleteInUseFile(inUsePath); + } catch (IOException e) { + LOG.warn("Unable to delete in-use-file for {}. Must be cleaned manually.", inUsePath); + } } + //else: will be cleaned up by {@link CryptoFileSystem#delete} } } @@ -184,12 +185,10 @@ public static Path computeInUseFilePath(Path p) { //-- for testing only InUseFile(AtomicReference currentFilePath, // - Consumer eventConsumer, // String fileSystemOwner, // SeekableByteChannel inUseFileChannel, // Properties info) { this.currentFilePath = currentFilePath; - this.eventConsumer = eventConsumer; this.fileSystemOwner = fileSystemOwner; this.inUseFileChannel = inUseFileChannel; this.info = info; diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 1e831e8a..c3b1d184 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -9,6 +9,10 @@ import org.cryptomator.cryptofs.common.RunnableThrowingException; import org.cryptomator.cryptofs.dir.CiphertextDirectoryDeleter; import org.cryptomator.cryptofs.dir.DirectoryStreamFactory; +import org.cryptomator.cryptofs.event.FileIsInUseEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; +import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptofs.fh.InUseFile; import org.cryptomator.cryptofs.fh.OpenCryptoFile; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptofs.fh.OpenCryptoFiles.TwoPhaseMove; @@ -22,6 +26,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import java.io.IOException; @@ -64,6 +70,7 @@ import java.util.Iterator; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import static java.nio.charset.StandardCharsets.UTF_8; import static org.cryptomator.cryptofs.matchers.ByteBufferMatcher.contains; @@ -108,6 +115,7 @@ public class CryptoFileSystemImplTest { private final ReadonlyFlag readonlyFlag = mock(ReadonlyFlag.class); private final CryptoFileSystemProperties fileSystemProperties = mock(CryptoFileSystemProperties.class); private final FileNameDecryptor filenameDecryptor = mock(FileNameDecryptor.class); + private final Consumer eventConsumer = mock(Consumer.class); private final CryptoPath root = mock(CryptoPath.class); private final CryptoPath empty = mock(CryptoPath.class); @@ -130,7 +138,7 @@ public void setup() { pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup, // fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, // openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, // - fileSystemProperties, filenameDecryptor); + fileSystemProperties, filenameDecryptor, eventConsumer); } @Test @@ -554,6 +562,17 @@ public void testNewFileChannelReadWriteShortened() throws IOException { verify(ciphertextPath).persistLongFileName(); } + @Test + @DisplayName("newFileChannel fails if used by another file") + public void testNewFileChannelInUseFailure() throws IOException { + when(openCryptoFile.newFileChannel(any(), eq(false))).thenThrow(FileAlreadyInUseException.class); + + Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.newFileChannel(cleartextPath, EnumSet.of(StandardOpenOption.WRITE))); + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent + && ((FileIsInUseEvent) ev).cleartext().equals(cleartextPath); + verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); + } + } @Nested diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index 55f22d3b..d0df6cea 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -1,47 +1,39 @@ package org.cryptomator.cryptofs.fh; import org.cryptomator.cryptofs.common.Constants; -import org.cryptomator.cryptofs.event.FileIsInUseEvent; -import org.cryptomator.cryptofs.event.FilesystemEvent; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.api.io.TempDir; -import org.mockito.ArgumentMatcher; -import org.mockito.ArgumentMatchers; import java.io.IOException; import java.nio.channels.SeekableByteChannel; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.Properties; -import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; -public class InUseFileTest { - /* - To test: - * acquire - * readInUseFile - * createInUseFile - * writeInUseFile - * ownInUseFile - * close - * getInUseFilePath - */ +/* + To test: + * acquire + * readInUseFile + * createInUseFile + * writeInUseFile + * ownInUseFile + * close + * getInUseFilePath + */ +public class InUseFileTest { Path ciphertextPath = mock(Path.class, "ciphertextPath"); AtomicReference currentFilePath = new AtomicReference<>(); - Consumer eventConsumer = mock(Consumer.class); SeekableByteChannel inUseChannel = mock(SeekableByteChannel.class); Properties info = new Properties(); InUseFile inUseFile; @@ -49,7 +41,7 @@ public class InUseFileTest { @BeforeEach public void beforeEach() { currentFilePath.set(ciphertextPath); - inUseFile = new InUseFile(currentFilePath, eventConsumer, "cryptobot", inUseChannel, info); + inUseFile = new InUseFile(currentFilePath, "cryptobot", inUseChannel, info); } @Test @@ -87,8 +79,6 @@ public void testAcquireExistingDifferentOwnerThrows() throws IOException { verify(inUseFileSpy, never()).createInUseFile(inUsePath); verify(inUseFileSpy, never()).stealInUseFile(inUsePath); - var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent; - verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); } } @@ -212,14 +202,67 @@ public void testCloseFailing() throws IOException { Path inUsePath = mock(Path.class, "inUseFilePath"); try (var classMock = mockStatic(InUseFile.class)) { classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); + Assertions.assertDoesNotThrow(inUseFileSpy::close); + + doThrow(IOException.class).when(inUseChannel).close(); + Assertions.assertDoesNotThrow(inUseFileSpy::close); + } + } + + @Test + @DisplayName("Closing after first close() does nothing") + public void testClosedForGood() throws IOException { + var inUseFileSpy = spy(inUseFile); + Path inUsePath = mock(Path.class, "inUseFilePath"); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + doNothing().when(inUseFileSpy).deleteInUseFile(any()); + doNothing().when(inUseChannel).close(); + + Assertions.assertDoesNotThrow(inUseFileSpy::close); + verify(inUseChannel, times(1)).close(); + verify(inUseFileSpy, times(1)).deleteInUseFile(inUsePath); Assertions.assertDoesNotThrow(inUseFileSpy::close); + verifyNoMoreInteractions(inUseChannel); + verify(inUseFileSpy, times(1)).deleteInUseFile(inUsePath); + } + } + @Test + @DisplayName("Closing with failure still marks the file as closed") + public void testClosedForGoodWithFailure() throws IOException { + var inUseFileSpy = spy(inUseFile); + Path inUsePath = mock(Path.class, "inUseFilePath"); + try (var classMock = mockStatic(InUseFile.class)) { + classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); + doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); doThrow(IOException.class).when(inUseChannel).close(); + Assertions.assertDoesNotThrow(inUseFileSpy::close); + verify(inUseChannel, times(1)).close(); + verify(inUseFileSpy, times(1)).deleteInUseFile(inUsePath); + + Assertions.assertDoesNotThrow(inUseFileSpy::close); + verifyNoMoreInteractions(inUseChannel); + verify(inUseFileSpy, times(1)).deleteInUseFile(inUsePath); } } + @Test + @DisplayName("Closing with null as currentFilePath skips deletion") + public void testCloseWithNullFilePath() throws IOException { + currentFilePath.set(null); + var inUseFileSpy = spy(inUseFile); + try (var classMock = mockStatic(InUseFile.class)) { + doNothing().when(inUseChannel).close(); + Assertions.assertDoesNotThrow(inUseFileSpy::close); + + verify(inUseFileSpy, never()).deleteInUseFile(any()); + classMock.verify(() -> InUseFile.computeInUseFilePath(any()), never()); + } + } } From 85f7ea9c090468ab53f39c2056ce3b39cb22aa48 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 12 Aug 2025 15:20:19 +0200 Subject: [PATCH 19/95] renaming method --- .../java/org/cryptomator/cryptofs/fh/InUseFile.java | 4 ++-- .../org/cryptomator/cryptofs/fh/InUseFileTest.java | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index 07c98389..5f29660b 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -49,7 +49,7 @@ synchronized boolean acquire() throws FileAlreadyInUseException { var inUseFilePath = computeInUseFilePath(ciphertextPath); var selfUseSuccessful = false; try { - if (isInUse(inUseFilePath, fileSystemOwner)) { + if (isInUseInternal(inUseFilePath, fileSystemOwner)) { throw new FileAlreadyInUseException(ciphertextPath); } selfUseSuccessful = updateInUseFile(inUseFilePath); @@ -82,7 +82,7 @@ private boolean updateInUseFile(Path inUseFilePath) { * @throws IOException if the in-use-file does not exist or cannot be read * @throws IllegalArgumentException if the in-use-file is invalid */ - static boolean isInUse(Path inUseFilePath, String fileSystemOwner) throws IOException, IllegalArgumentException { + static boolean isInUseInternal(Path inUseFilePath, String fileSystemOwner) throws IOException, IllegalArgumentException { Properties content = readInUseFile(inUseFilePath); if (!content.get("owner").equals(fileSystemOwner)) { //TODO: check also timestamps diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java index d0df6cea..7e455ed7 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java @@ -51,7 +51,7 @@ public void testAcquireExistingSameOwner() throws IOException { Path inUsePath = mock(Path.class, "inUseFilePath"); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenReturn(false); + classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenReturn(false); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); var isInUse = inUseFileSpy.acquire(); @@ -70,7 +70,7 @@ public void testAcquireExistingDifferentOwnerThrows() throws IOException { Path inUsePath = mock(Path.class, "inUseFilePath"); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenReturn(true); + classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenReturn(true); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); Executable test = () -> inUseFile.acquire(); @@ -89,7 +89,7 @@ public void testAcquireReadExistingInvalid() throws IOException { Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(true).when(inUseFileSpy).stealInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenThrow(IllegalArgumentException.class); + classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenThrow(IllegalArgumentException.class); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); var isAcquired = inUseFileSpy.acquire(); @@ -107,7 +107,7 @@ public void testAcquireReadExistingInvalidFailedSteal() throws IOException { Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(false).when(inUseFileSpy).stealInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenThrow(IllegalArgumentException.class); + classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenThrow(IllegalArgumentException.class); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); when(inUseFileSpy.stealInUseFile(inUsePath)).thenReturn(false); @@ -126,7 +126,7 @@ public void testAcquireCreateNew() throws IOException { Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(true).when(inUseFileSpy).createInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenThrow(NoSuchFileException.class); + classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenThrow(NoSuchFileException.class); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); var isAcquired = inUseFileSpy.acquire(); @@ -144,7 +144,7 @@ public void testAcquireCreateNewFailing() throws IOException { Path inUsePath = mock(Path.class, "inUseFilePath"); doReturn(false).when(inUseFileSpy).createInUseFile(inUsePath); try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUse(eq(inUsePath), any())).thenThrow(NoSuchFileException.class); + classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenThrow(NoSuchFileException.class); classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); when(inUseFileSpy.createInUseFile(inUsePath)).thenReturn(false); From 6c071de5f2e02e403a9c888290319d579accfda4 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 12 Aug 2025 17:21:43 +0200 Subject: [PATCH 20/95] implement in-use-check for moveFile --- .../cryptofs/CryptoFileSystemImpl.java | 9 +++- .../cryptomator/cryptofs/fh/InUseFile.java | 23 ++++++++++ .../cryptofs/fh/OpenCryptoFile.java | 5 +- .../cryptofs/CryptoFileSystemImplTest.java | 46 +++++++++++++++---- .../cryptofs/fh/OpenCryptoFileTest.java | 29 +++++++++++- 5 files changed, 98 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index a6d5e573..00e13456 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -619,7 +619,14 @@ private void moveFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, Co CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { - //TODO: fail, if foreign inUseFile exists + if (InUseFile.isInUse(ciphertextSource.getFilePath(), "owner")) { ; //TODO: get the filesystemowner + eventConsumer.accept(new FileIsInUseEvent(cleartextSource, ciphertextSource.getRawPath(), new Properties())); //TODO: properties?? + throw new FileAlreadyInUseException(ciphertextSource.getRawPath()); + } + if (InUseFile.isInUse(ciphertextTarget.getFilePath(), "owner")) { ; //TODO: get the filesystemowner + eventConsumer.accept(new FileIsInUseEvent(cleartextTarget, ciphertextTarget.getRawPath(), new Properties())); //TODO: properties?? + throw new FileAlreadyInUseException(ciphertextTarget.getRawPath()); + } if (ciphertextTarget.isShortened()) { Files.createDirectories(ciphertextTarget.getRawPath()); ciphertextTarget.persistLongFileName(); diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java index 5f29660b..2cee0b7d 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java @@ -73,6 +73,20 @@ private boolean updateInUseFile(Path inUseFilePath) { return true; } + /** + * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running cryptofile system. + * + * @return {@code true} if the in-use-file exists, is valid, but owned by different user. Otherwise {@code false}. + */ + public static boolean isInUse(Path ciphertextPath, String owner) { + var inUseFile = computeInUseFilePath(ciphertextPath); + try { + return isInUseInternal(inUseFile, owner); + } catch (IllegalArgumentException | IOException e) { + return false; + } + } + /** * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running cryptofile system. * @@ -144,6 +158,15 @@ void deleteInUseFile(Path inUseFilePath) throws IOException { Files.deleteIfExists(inUseFilePath); } + synchronized void move(Path source) { + var target = currentFilePath.get(); + try { + Files.move(source, target); + } catch (IOException e) { + LOG.warn("Could not move in-use-file from {} to {}", source, target, e); + } + } + @Override public synchronized void close() { diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 23615847..aee704a4 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -174,7 +174,10 @@ public Path getCurrentFilePath() { * @param newFilePath new ciphertext path */ public void updateCurrentFilePath(Path newFilePath) { - currentFilePath.updateAndGet(p -> p == null ? null : newFilePath); + var oldPath = currentFilePath.getAndUpdate(p -> p == null ? null : newFilePath); + if(newFilePath != null) { //otherwise file got deleted + inUseFile.move(oldPath); + } } private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChannel) { diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index c3b1d184..fa89276d 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -80,15 +80,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import static org.mockito.internal.verification.VerificationModeFactory.atLeast; public class CryptoFileSystemImplTest { @@ -795,13 +787,47 @@ public void moveFile() throws IOException { CopyOption option1 = mock(CopyOption.class); CopyOption option2 = mock(CopyOption.class); - inTest.move(cleartextSource, cleartextDestination, option1, option2); + try (var inUseClass = mockStatic(InUseFile.class)) { + inUseClass.when(() -> InUseFile.isInUse(eq(ciphertextDestinationFile), any())).thenReturn(false); + inTest.move(cleartextSource, cleartextDestination, option1, option2); + } verify(readonlyFlag).assertWritable(); verify(physicalFsProv).move(ciphertextSourceFile, ciphertextDestinationFile, option1, option2); verify(openFileMove).commit(); } + @Test + public void moveFileWithSourceInUse() throws IOException { + moveFileWithXInUse(ciphertextSourceFile, cleartextSource); + } + + @Test + public void moveFileWithTargetInUse() throws IOException { + moveFileWithXInUse(ciphertextDestinationFile, cleartextDestination); + } + + private void moveFileWithXInUse(Path usedCipherPath, CryptoPath usedClearPath) 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); + + CopyOption option1 = mock(CopyOption.class); + CopyOption option2 = mock(CopyOption.class); + + try (var inUseClass = mockStatic(InUseFile.class)) { + inUseClass.when(() -> InUseFile.isInUse(eq(usedCipherPath), any())).thenReturn(true); + Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.move(cleartextSource, cleartextDestination, option1, option2)); + } + verify(readonlyFlag).assertWritable(); + verify(physicalFsProv, never()).move(ciphertextSourceFile, ciphertextDestinationFile, option1, option2); + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent + && ((FileIsInUseEvent) ev).cleartext().equals(usedClearPath); + verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); + } + + @Test public void moveDirectoryDontReplaceExisting() throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextSource)).thenReturn(CiphertextFileType.DIRECTORY); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index daf27911..7ee9c4f1 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -36,6 +36,7 @@ import java.util.function.Consumer; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -126,8 +127,6 @@ public void testInUseFileThrowsException() throws FileAlreadyInUseException { }); } - //TODO: test, if in-use-file-exists, but it is ignored with flag - @Test @DisplayName("if the second file channel fails to open, do nothing") public void testFailedSecondFileChannelDoesNothing() throws IOException { @@ -176,6 +175,32 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce verify(cleartextChannel).truncate(0L); } + @Test + @DisplayName("Updating the current file path moves the inUse file") + 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, inUseFile); + + doNothing().when(inUseFile).move(currentPath); + + openCryptoFile.updateCurrentFilePath(newPath); + verify(inUseFile).move(currentPath); + } + + @Test + @DisplayName("Updating the current file path with null skips in-use-file") + 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, inUseFile); + + openCryptoFile.updateCurrentFilePath(null); + verify(inUseFile, never()).move(any()); + } + + @Nested @DisplayName("Testing ::initFileHeader") public class InitFilHeaderTests { From 83388ad3550fa9d10553ec563f8ad244c3a7d912 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 12 Aug 2025 17:41:29 +0200 Subject: [PATCH 21/95] implement in-use-feature for deleteFile() --- .../cryptofs/CryptoFileSystemImpl.java | 18 ++++++++-- .../cryptofs/CryptoFileSystemImplTest.java | 33 +++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 00e13456..6a6d795e 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -442,11 +442,25 @@ void delete(CryptoPath cleartextPath) throws IOException { CiphertextFilePath ciphertextPath = cryptoPathMapper.getCiphertextFilePath(cleartextPath); switch (ciphertextFileType) { case DIRECTORY -> deleteDirectory(cleartextPath, ciphertextPath); - case FILE, SYMLINK -> deleteFileOrSymlink(ciphertextPath); + case FILE -> deleteFile(ciphertextPath); + case SYMLINK -> deleteSymlink(ciphertextPath); } } - private void deleteFileOrSymlink(CiphertextFilePath ciphertextPath) throws IOException { + private void deleteFile(CiphertextFilePath ciphertextPath) throws IOException { + openCryptoFiles.delete(ciphertextPath.getFilePath()); + Files.walkFileTree(ciphertextPath.getRawPath(), DeletingFileVisitor.INSTANCE); + if(!ciphertextPath.isShortened()) { + try { + Files.deleteIfExists(InUseFile.computeInUseFilePath(ciphertextPath.getFilePath())); + } catch (IOException e) { + //no-op + //TODO: log as info? + } + } + } + + private void deleteSymlink(CiphertextFilePath ciphertextPath) throws IOException { openCryptoFiles.delete(ciphertextPath.getFilePath()); Files.walkFileTree(ciphertextPath.getRawPath(), DeletingFileVisitor.INSTANCE); } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index fa89276d..b4095bc2 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -604,12 +604,41 @@ public void testDeleteRootFails() { } @Test - public void testDeleteExistingFile() throws IOException { + public void testDeleteRegularExistingFile() throws IOException { + var inUsePath = mock(Path.class, "in use file"); + when(inUsePath.getFileSystem()).thenReturn(physicalFs); when(cryptoPathMapper.getCiphertextFileType(cleartextPath)).thenReturn(CiphertextFileType.FILE); when(physicalFsProv.deleteIfExists(ciphertextRawPath)).thenReturn(true); + when(physicalFsProv.deleteIfExists(inUsePath)).thenReturn(true); doNothing().when(openCryptoFiles).delete(Mockito.any()); + when(ciphertextPath.isShortened()).thenReturn(false); - inTest.delete(cleartextPath); + + try(var inUseClassMock = mockStatic(InUseFile.class)) { + inUseClassMock.when(() -> InUseFile.computeInUseFilePath(ciphertextFilePath)).thenReturn(inUsePath); + inTest.delete(cleartextPath); + + inUseClassMock.verify(() -> InUseFile.computeInUseFilePath(ciphertextFilePath)); + } + + verify(readonlyFlag).assertWritable(); + verify(openCryptoFiles).delete(ciphertextFilePath); + verify(physicalFsProv).deleteIfExists(ciphertextRawPath); + verify(physicalFsProv).deleteIfExists(inUsePath); + } + + @Test + public void testDeleteShortenedExistingFile() throws IOException { + when(cryptoPathMapper.getCiphertextFileType(cleartextPath)).thenReturn(CiphertextFileType.FILE); + when(physicalFsProv.deleteIfExists(ciphertextRawPath)).thenReturn(true); + doNothing().when(openCryptoFiles).delete(Mockito.any()); + when(ciphertextPath.isShortened()).thenReturn(true); + + try(var inUseClassMock = mockStatic(InUseFile.class)) { + inTest.delete(cleartextPath); + + inUseClassMock.verify(() -> InUseFile.computeInUseFilePath(ciphertextFilePath), never()); + } verify(readonlyFlag).assertWritable(); verify(openCryptoFiles).delete(ciphertextFilePath); From 0f13de93a94f2b4c6b7d7352e9462fd6516aaa67 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 13 Aug 2025 10:34:42 +0200 Subject: [PATCH 22/95] cleanup --- .../cryptofs/fh/OpenCryptoFile.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index aee704a4..81afcdf6 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -121,36 +121,35 @@ private void closeQuietly(Closeable closeable) { } /** - * Called by {@link #newFileChannel(EffectiveOpenOptions, FileAttribute[])} to determine the fileSize. + * Called by {@link #newFileChannel(EffectiveOpenOptions, boolean, FileAttribute[])} to determine the fileSize. *

* Before the size is initialized (i.e. before a channel has been created), {@link #size()} must not be called. *

* Initialization happens at most once per open file. Subsequent invocations are no-ops. */ private void initFileSize(FileChannel ciphertextFileChannel) throws IOException { - if (fileSize.get() == -1l) { + if (fileSize.get() == -1L) { LOG.trace("First channel for this openFile. Initializing file size..."); - long cleartextSize = 0l; + long cleartextSize = 0L; try { long ciphertextSize = ciphertextFileChannel.size(); - if (ciphertextSize > 0l) { + if (ciphertextSize > 0L) { long payloadSize = ciphertextSize - cryptor.fileHeaderCryptor().headerSize(); cleartextSize = cryptor.fileContentCryptor().cleartextSize(payloadSize); } } catch (IllegalArgumentException e) { LOG.warn("Invalid cipher text file size. Assuming empty file.", e); - assert cleartextSize == 0l; } - fileSize.compareAndSet(-1l, cleartextSize); + fileSize.compareAndSet(-1L, cleartextSize); } } /** - * @return The size of the opened file. Note that the filesize is unknown until a {@link #newFileChannel(EffectiveOpenOptions, FileAttribute[])} is opened. In this case this method returns an empty optional. + * @return The size of the opened file. Note that the filesize is unknown until a {@link #newFileChannel(EffectiveOpenOptions, boolean, FileAttribute[])} is opened. In this case this method returns an empty optional. */ public Optional size() { long val = fileSize.get(); - if (val == -1l) { + if (val == -1L) { return Optional.empty(); } else { return Optional.of(val); @@ -171,13 +170,15 @@ public Path getCurrentFilePath() { /** * Updates the current ciphertext file path, if it is not already set to null (i.e., the openCryptoFile is deleted) + * * @param newFilePath new ciphertext path */ public void updateCurrentFilePath(Path newFilePath) { var oldPath = currentFilePath.getAndUpdate(p -> p == null ? null : newFilePath); - if(newFilePath != null) { //otherwise file got deleted + if (newFilePath != null) { inUseFile.move(oldPath); } + //else file got deleted and the in-use-file will be deleted in {@link CryptoFileSystem#delete} } private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChannel) { From 8f443d828f1d3aaa877dacd36fc20f8d2380ae74 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 13 Aug 2025 11:15:45 +0200 Subject: [PATCH 23/95] add in-use-check to deleteFile --- .../cryptofs/CryptoFileSystemImpl.java | 23 ++++++------ .../cryptofs/CryptoFileSystemImplTest.java | 35 +++++++++++++++---- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 6a6d795e..dc5c55ff 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -442,12 +442,13 @@ void delete(CryptoPath cleartextPath) throws IOException { CiphertextFilePath ciphertextPath = cryptoPathMapper.getCiphertextFilePath(cleartextPath); switch (ciphertextFileType) { case DIRECTORY -> deleteDirectory(cleartextPath, ciphertextPath); - case FILE -> deleteFile(ciphertextPath); + case FILE -> deleteFile(cleartextPath, ciphertextPath); case SYMLINK -> deleteSymlink(ciphertextPath); } } - private void deleteFile(CiphertextFilePath ciphertextPath) throws IOException { + private void deleteFile(CryptoPath cleartextPath, CiphertextFilePath ciphertextPath) throws IOException { + checkUsage(cleartextPath, ciphertextPath); openCryptoFiles.delete(ciphertextPath.getFilePath()); Files.walkFileTree(ciphertextPath.getRawPath(), DeletingFileVisitor.INSTANCE); if(!ciphertextPath.isShortened()) { @@ -633,14 +634,9 @@ private void moveFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, Co CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { - if (InUseFile.isInUse(ciphertextSource.getFilePath(), "owner")) { ; //TODO: get the filesystemowner - eventConsumer.accept(new FileIsInUseEvent(cleartextSource, ciphertextSource.getRawPath(), new Properties())); //TODO: properties?? - throw new FileAlreadyInUseException(ciphertextSource.getRawPath()); - } - if (InUseFile.isInUse(ciphertextTarget.getFilePath(), "owner")) { ; //TODO: get the filesystemowner - eventConsumer.accept(new FileIsInUseEvent(cleartextTarget, ciphertextTarget.getRawPath(), new Properties())); //TODO: properties?? - throw new FileAlreadyInUseException(ciphertextTarget.getRawPath()); - } + //TODO: skip this if owner is not set in Properties + checkUsage(cleartextSource, ciphertextSource); + checkUsage(cleartextTarget, ciphertextTarget); if (ciphertextTarget.isShortened()) { Files.createDirectories(ciphertextTarget.getRawPath()); ciphertextTarget.persistLongFileName(); @@ -736,4 +732,11 @@ public String toString() { return format("%sCryptoFileSystem(%s)", open ? "" : "closed ", pathToVault); } + private void checkUsage(CryptoPath cleartextPath, CiphertextFilePath ciphertextPath) throws FileAlreadyInUseException { + if (InUseFile.isInUse(ciphertextPath.getFilePath(), "owner")) { ; //TODO: get the filesystemowner + eventConsumer.accept(new FileIsInUseEvent(cleartextPath, ciphertextPath.getRawPath(), new Properties())); //TODO: properties?? + throw new FileAlreadyInUseException(ciphertextPath.getRawPath()); + } + } + } \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index b4095bc2..71f9bffd 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -457,7 +457,7 @@ public void setup() throws IOException { when(ciphertextPath.getFilePath()).thenReturn(ciphertextFilePath); when(openCryptoFiles.getOrCreate(ciphertextFilePath)).thenReturn(openCryptoFile); when(ciphertextFilePath.getName(3)).thenReturn(mock(CryptoPath.class, "path.c9r")); - when(openCryptoFile.newFileChannel(any(), anyBoolean(), any(FileAttribute[].class))).thenReturn(fileChannel); + when(openCryptoFile.newFileChannel(any(), anyBoolean(), any(FileAttribute[].class))).thenReturn(fileChannel); } @Nested @@ -614,11 +614,9 @@ public void testDeleteRegularExistingFile() throws IOException { when(ciphertextPath.isShortened()).thenReturn(false); - try(var inUseClassMock = mockStatic(InUseFile.class)) { + try (var inUseClassMock = mockStatic(InUseFile.class)) { inUseClassMock.when(() -> InUseFile.computeInUseFilePath(ciphertextFilePath)).thenReturn(inUsePath); inTest.delete(cleartextPath); - - inUseClassMock.verify(() -> InUseFile.computeInUseFilePath(ciphertextFilePath)); } verify(readonlyFlag).assertWritable(); @@ -629,22 +627,45 @@ public void testDeleteRegularExistingFile() throws IOException { @Test public void testDeleteShortenedExistingFile() throws IOException { + var inUsePath = mock(Path.class, "in use file"); when(cryptoPathMapper.getCiphertextFileType(cleartextPath)).thenReturn(CiphertextFileType.FILE); when(physicalFsProv.deleteIfExists(ciphertextRawPath)).thenReturn(true); doNothing().when(openCryptoFiles).delete(Mockito.any()); when(ciphertextPath.isShortened()).thenReturn(true); - try(var inUseClassMock = mockStatic(InUseFile.class)) { + try (var inUseClassMock = mockStatic(InUseFile.class)) { + inUseClassMock.when(() -> InUseFile.computeInUseFilePath(ciphertextFilePath)).thenReturn(inUsePath); inTest.delete(cleartextPath); - - inUseClassMock.verify(() -> InUseFile.computeInUseFilePath(ciphertextFilePath), never()); } verify(readonlyFlag).assertWritable(); verify(openCryptoFiles).delete(ciphertextFilePath); verify(physicalFsProv).deleteIfExists(ciphertextRawPath); + verify(physicalFsProv, never()).deleteIfExists(inUsePath); + } + + @Test + public void testDeleteInUseFileThrows() throws IOException { + var inUsePath = mock(Path.class, "in use file"); + when(cryptoPathMapper.getCiphertextFileType(cleartextPath)).thenReturn(CiphertextFileType.FILE); + when(physicalFsProv.deleteIfExists(ciphertextRawPath)).thenReturn(true); + doNothing().when(openCryptoFiles).delete(Mockito.any()); + when(ciphertextPath.isShortened()).thenReturn(false); + + try (var inUseClassMock = mockStatic(InUseFile.class)) { + inUseClassMock.when(() -> InUseFile.isInUse(eq(ciphertextFilePath), anyString())).thenReturn(true); + Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.delete(cleartextPath)); + } + + verify(openCryptoFiles, never()).delete(ciphertextFilePath); + verify(physicalFsProv, never()).deleteIfExists(ciphertextRawPath); + verify(physicalFsProv, never()).deleteIfExists(inUsePath); + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent // + && ((FileIsInUseEvent) ev).cleartext().equals(cleartextPath); + verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); } + @Test public void testDeleteExistingDirectory() throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextPath)).thenReturn(CiphertextFileType.DIRECTORY); From f92b4e0490135b8f40c7a09cd3712138cd59c1ad Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 14 Aug 2025 22:11:34 +0200 Subject: [PATCH 24/95] async approach --- .../org/cryptomator/cryptofs/fh/UseToken.java | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/main/java/org/cryptomator/cryptofs/fh/UseToken.java diff --git a/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java b/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java new file mode 100644 index 00000000..f21f6a26 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java @@ -0,0 +1,168 @@ +package org.cryptomator.cryptofs.fh; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class UseToken implements Closeable { + + public static UseToken createWithNewFile(Path p, ConcurrentMap useTokens) { + return new UseToken(p, useTokens, ActivationType.CREATE); + } + + public static UseToken createWithExistingFile(Path p, ConcurrentMap useTokens) { + return new UseToken(p, useTokens, ActivationType.UPDATE); + } + + public static UseToken createWithExistingInvalidFile(Path p, ConcurrentMap useTokens) { + return new UseToken(p, useTokens, ActivationType.STEAL); + } + + + private UseToken(Path p, ConcurrentMap useTokens, ActivationType m, Cryptor cryptor,) { + this.p = p; + this.useTokens = useTokens; + FileOperation method = switch (m) { + case STEAL -> this::stealInUseFile; + case UPDATE -> this::updateInUseFile; + case CREATE -> this::createInUseFile; + }; + + this.creationTask = CompletableFuture.runAsync(() -> { + try { + fileCreationSync.lock(); + if (closed) { + return; + } + //Do critical stuff + method.execute(); + } catch (IOException e) { + closed = true; + } finally { + fileCreationSync.unlock(); + } + }); //TODO: delayed executor + } + + private final CompletableFuture creationTask; + private final ConcurrentMap useTokens; + private final ReentrantReadWriteLock.WriteLock fileCreationSync = new ReentrantReadWriteLock().writeLock(); + + private volatile Path p; + private volatile SeekableByteChannel channel; + private volatile boolean closed = false; + + + private void stealInUseFile() { + try { + writeInUseFile(p, Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)); + } catch (IOException e) { + //LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + } + } + + private void createInUseFile() { + try { + writeInUseFile(p, Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)); + } catch (IOException e) { + //LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + } + } + + private void updateInUseFile() { + try { + writeInUseFile(p, Set.of(StandardOpenOption.WRITE)); + } catch (IOException e) { + //LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + } + } + + boolean writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { + this.channel = Files.newByteChannel(inUseFilePath, openOptions); //TODO: delete on close? + var rawInfo = new ByteArrayOutputStream(4_000); + new Properties().store(rawInfo, "UNENCRYPTED Cryptomator inUse file"); + //TODO: encryption + channel.write(ByteBuffer.wrap(rawInfo.toByteArray())); + channel.position(0); + return true; + } + + void move(Path newPath) { + try { + //sync with file creation + fileCreationSync.lock(); + + if (closed) { + return; + } + useTokens.compute(newPath, (p, t) -> { + try { + Files.move(p, newPath, StandardCopyOption.REPLACE_EXISTING); + return this; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + useTokens.remove(p); + this.p = newPath; + } catch (UncheckedIOException e) { + //TODO: Log + close(); //To prevent invalid states + } finally { + fileCreationSync.unlock(); + } + } + + + @Override + public void close() { + try { + //sync with file creation + fileCreationSync.lock(); + + if (closed) { + return; + } + creationTask.cancel(false); + useTokens.compute(p, (path, token) -> { + closed = true; + if (channel != null) { + try { + channel.close(); + } catch (IOException e) { + //ignore + } + //delete file if channel != null + } + return null; + }); + } finally { + fileCreationSync.unlock(); + } + } + + enum ActivationType { + UPDATE, + CREATE, + STEAL; + } + + @FunctionalInterface + interface FileOperation { + + void execute() throws IOException; + } +} From 842b3c68bfb81e0e8067ecb8bdf9a2ebc115cf07 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 27 Aug 2025 14:07:36 +0200 Subject: [PATCH 25/95] Refactor useToken and add unit tests * close token on failed file creation * add logging * only move file if file is already created * delete file on close --- pom.xml | 6 + .../org/cryptomator/cryptofs/fh/UseToken.java | 83 ++++---- .../cryptomator/cryptofs/fh/UseTokenTest.java | 179 ++++++++++++++++++ 3 files changed, 235 insertions(+), 33 deletions(-) create mode 100644 src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java diff --git a/pom.xml b/pom.xml index ea0b8817..e3aeae0d 100644 --- a/pom.xml +++ b/pom.xml @@ -165,6 +165,12 @@ ${jimfs.version} test + + org.awaitility + awaitility + 4.3.0 + test + diff --git a/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java b/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java index f21f6a26..ed3e6477 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java @@ -1,5 +1,8 @@ package org.cryptomator.cryptofs.fh; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; @@ -11,10 +14,13 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.time.Instant; import java.util.Properties; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantReadWriteLock; public class UseToken implements Closeable { @@ -31,9 +37,18 @@ public static UseToken createWithExistingInvalidFile(Path p, ConcurrentMap creationTask; + private final ConcurrentMap useTokens; + private final ReentrantReadWriteLock.WriteLock fileCreationSync = new ReentrantReadWriteLock().writeLock(); - private UseToken(Path p, ConcurrentMap useTokens, ActivationType m, Cryptor cryptor,) { - this.p = p; + private volatile Path filePath; + private volatile SeekableByteChannel channel; + private volatile boolean closed = false; + + private UseToken(Path filePath, ConcurrentMap useTokens, ActivationType m) { + this.filePath = filePath; this.useTokens = useTokens; FileOperation method = switch (m) { case STEAL -> this::stealInUseFile; @@ -50,54 +65,50 @@ private UseToken(Path p, ConcurrentMap useTokens, ActivationType //Do critical stuff method.execute(); } catch (IOException e) { - closed = true; + close(); } finally { fileCreationSync.unlock(); } - }); //TODO: delayed executor + }, CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS, Executors.newVirtualThreadPerTaskExecutor())); } - private final CompletableFuture creationTask; - private final ConcurrentMap useTokens; - private final ReentrantReadWriteLock.WriteLock fileCreationSync = new ReentrantReadWriteLock().writeLock(); - - private volatile Path p; - private volatile SeekableByteChannel channel; - private volatile boolean closed = false; - - - private void stealInUseFile() { + private void stealInUseFile() throws IOException { try { - writeInUseFile(p, Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)); + writeInUseFile(filePath, Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)); } catch (IOException e) { - //LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + LOG.warn("Failed to steal in-use file {}.", filePath, e); + throw e; } } - private void createInUseFile() { + private void createInUseFile() throws IOException { try { - writeInUseFile(p, Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)); + writeInUseFile(filePath, Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)); } catch (IOException e) { - //LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + LOG.warn("Failed to create in-use file {}.", filePath, e); + throw e; } } - private void updateInUseFile() { + private void updateInUseFile() throws IOException { try { - writeInUseFile(p, Set.of(StandardOpenOption.WRITE)); + writeInUseFile(filePath, Set.of(StandardOpenOption.WRITE)); } catch (IOException e) { - //LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); + LOG.warn("Failed to update in-use file {}.", filePath, e); + throw e; } } - boolean writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { - this.channel = Files.newByteChannel(inUseFilePath, openOptions); //TODO: delete on close? + void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { + this.channel = Files.newByteChannel(inUseFilePath, openOptions); var rawInfo = new ByteArrayOutputStream(4_000); - new Properties().store(rawInfo, "UNENCRYPTED Cryptomator inUse file"); + var prop = new Properties(); + prop.put("owner", "owner"); //TODO: add real info + prop.put("owningSince", Instant.now().toString()); + prop.store(rawInfo, "UNENCRYPTED Cryptomator inUse file"); //TODO: encryption channel.write(ByteBuffer.wrap(rawInfo.toByteArray())); channel.position(0); - return true; } void move(Path newPath) { @@ -110,22 +121,27 @@ void move(Path newPath) { } useTokens.compute(newPath, (p, t) -> { try { - Files.move(p, newPath, StandardCopyOption.REPLACE_EXISTING); + if (channel != null) { + Files.move(filePath, newPath, StandardCopyOption.REPLACE_EXISTING); + } return this; } catch (IOException e) { throw new UncheckedIOException(e); } }); - useTokens.remove(p); - this.p = newPath; + useTokens.remove(filePath); + this.filePath = newPath; } catch (UncheckedIOException e) { - //TODO: Log + LOG.warn("Failed to move in-use file {} to {}.", filePath, newPath, e.getCause()); close(); //To prevent invalid states } finally { fileCreationSync.unlock(); } } + boolean isClosed() { + return closed; + } @Override public void close() { @@ -136,16 +152,17 @@ public void close() { if (closed) { return; } + closed = true; creationTask.cancel(false); - useTokens.compute(p, (path, token) -> { - closed = true; + useTokens.compute(filePath, (path, token) -> { if (channel != null) { try { channel.close(); + Files.deleteIfExists(filePath); } catch (IOException e) { //ignore + //TODO: LOG } - //delete file if channel != null } return null; }); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java new file mode 100644 index 00000000..5887bfcd --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java @@ -0,0 +1,179 @@ +package org.cryptomator.cryptofs.fh; + +import org.awaitility.Awaitility; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchService; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; + +public class UseTokenTest { + + private ConcurrentMap useTokens; + @TempDir + Path tmpDir; + private WatchService watchService; + + @BeforeEach + public void beforeEach() throws IOException { + useTokens = new ConcurrentHashMap<>();//Mockito.mock(ConcurrentMap.class); + watchService = tmpDir.getFileSystem().newWatchService(); + } + + @AfterEach + public void afterEach() { + try { + watchService.close(); + } catch (IOException e) { + //no-op + } + } + + @Test + @DisplayName("After 5 seconds of token creation, a new file is created") + public void testFileCreation() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + try (var token = UseToken.createWithNewFile(filePath, useTokens)) { + Awaitility.await().atLeast(5, TimeUnit.SECONDS).atMost(8, TimeUnit.SECONDS).until(() -> Files.exists(filePath)); + Assertions.assertTrue(Files.exists(filePath)); + } + Assertions.assertTrue(Files.notExists(filePath)); + } + + @Test + @DisplayName("After 5 seconds of token creation, a file is updated") + public void testFileSteal() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + Files.createFile(filePath); + var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); + var fileTime = Files.getLastModifiedTime(filePath); + + try (var token = UseToken.createWithExistingInvalidFile(filePath, useTokens)) { + Awaitility.await().atLeast(5, TimeUnit.SECONDS).atMost(8, TimeUnit.SECONDS).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); + var events = watchKey.pollEvents(); + var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); + Assertions.assertTrue(createEvent.isPresent()); + createEvent.ifPresent(e -> { + Assertions.assertEquals(1, e.count()); + Assertions.assertTrue(filePath.endsWith((Path) e.context())); + }); + } + Assertions.assertTrue(Files.notExists(filePath)); + Assertions.assertNull(useTokens.get(filePath)); + } + + @Test + @DisplayName("After 5 seconds of token creation, failed steal closes the token ") + public void testFileStealFails() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); //file does not exist + var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + + try (var token = UseToken.createWithExistingInvalidFile(filePath, useTokens)) { + Awaitility.await().atLeast(4950, TimeUnit.MILLISECONDS).atMost(10, TimeUnit.SECONDS).until(token::isClosed); + Assertions.assertTrue(Files.notExists(filePath)); + Assertions.assertTrue(token.isClosed()); + Assertions.assertNull(useTokens.get(filePath)); + } + MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); + } + + @Test + @DisplayName("Closing a UseToken before 5 seconds passed skips file creation") + public void testTokenCloseBeforeFileOperation() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + + try (var token = UseToken.createWithNewFile(filePath, useTokens)) { + Assertions.assertTrue(Files.notExists(filePath)); + } + Awaitility.await().pollDelay(7, TimeUnit.SECONDS).timeout(10, TimeUnit.SECONDS).until(() -> true); + Assertions.assertTrue(Files.notExists(filePath)); + Assertions.assertNull(useTokens.get(filePath)); + MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); + } + + @Test + @DisplayName("Moving a token before file creation") + public void testMoveBefore() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + var targetPath = tmpDir.resolve("inUse2.file"); + var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + + try (var token = UseToken.createWithNewFile(filePath, useTokens)) { + token.move(targetPath); + + //no file operation after move + MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); + // target file will be created + Awaitility.await().atLeast(5000, TimeUnit.MILLISECONDS).atMost(10, TimeUnit.SECONDS).until(() -> Files.exists(targetPath)); + //orginal filePath does not exist, target exists + Assertions.assertTrue(Files.notExists(filePath)); + Assertions.assertTrue(Files.exists(targetPath)); + + //only targetPath was created + var events = watchKey.pollEvents(); + var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_CREATE)).findAny(); + Assertions.assertTrue(createEvent.isPresent()); + createEvent.ifPresent(e -> { + Assertions.assertEquals(1, e.count()); + Assertions.assertTrue(targetPath.endsWith((Path) e.context())); + }); + } + Assertions.assertNull(useTokens.get(filePath)); + Assertions.assertNull(useTokens.get(targetPath)); + } + + @Test + @DisplayName("Moving a token after file creation") + public void testMoveAfter() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + var targetPath = tmpDir.resolve("inUse2.file"); + + try (var token = UseToken.createWithNewFile(filePath, useTokens)) { + Awaitility.await().atLeast(4950, TimeUnit.MILLISECONDS).atMost(10, TimeUnit.SECONDS).until(() -> Files.exists(filePath)); + + token.move(targetPath); + + // target file will be created + // orginal filePath does not exist, target exists + Assertions.assertTrue(Files.notExists(filePath)); + Assertions.assertTrue(Files.exists(targetPath)); + Assertions.assertNull(useTokens.get(filePath)); + Assertions.assertNotNull(useTokens.get(targetPath)); + } + Assertions.assertNull(useTokens.get(filePath)); + Assertions.assertNull(useTokens.get(targetPath)); + } + + @Test + @DisplayName("Moving does nothing on closed token") + public void testMoveClosed() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + var targetPath = tmpDir.resolve("inUse2.file"); + var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + + try (var token = UseToken.createWithNewFile(filePath, useTokens)) { + token.close(); + Awaitility.await().pollDelay(7, TimeUnit.SECONDS).timeout(10, TimeUnit.SECONDS).until(() -> true); + + + token.move(targetPath); + + MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); + Assertions.assertNull(useTokens.get(filePath)); + Assertions.assertNull(useTokens.get(targetPath)); + } + } +} From 3d2a009a005d9337c5b64b59852da3b95751cc45 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 27 Aug 2025 15:29:09 +0200 Subject: [PATCH 26/95] Enhancing UseToken: * add factory method to create InvalidToken * add instance variable owner * define in-use-file-creation threshold as a constant --- .../cryptofs/common/Constants.java | 1 + .../org/cryptomator/cryptofs/fh/UseToken.java | 76 +++++++++++++------ .../cryptomator/cryptofs/fh/UseTokenTest.java | 49 ++++++++---- 3 files changed, 87 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 1647c927..0627af07 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -36,4 +36,5 @@ private Constants() { public static final String SEPARATOR = "/"; public static final String RECOVERY_DIR_NAME = "LOST+FOUND"; + public static final int IN_USE_DELAY_MILLIS = 5000; } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java b/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java index ed3e6477..f20c4121 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java @@ -1,5 +1,6 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.common.Constants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,53 +24,79 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantReadWriteLock; +/** + * Class to represent a file is "in use" by this filesystem. + *

+ * The actual persistence of the "in use"-state with a file is delayed by {@value Constants#IN_USE_DELAY_MILLIS} milliseconds. + * The content of the in-use-file is a JSON containing + *

  • + *
      owner - name of the filesystem owner
    + *
      since - UTC-timestamp encoded as epoch seconds from the standard Java epoch of 1970-01-01T00:00:00Z
    + *
  • + * The JSON data is encrypted with the vault masterkey. + * If the token is closed before the persistence started, the persistence is not performed. + */ public class UseToken implements Closeable { - public static UseToken createWithNewFile(Path p, ConcurrentMap useTokens) { - return new UseToken(p, useTokens, ActivationType.CREATE); + public static UseToken createWithNewFile(Path p, String owner, ConcurrentMap useTokens) { + return new UseToken(p, owner, useTokens, ActivationType.CREATE); } - public static UseToken createWithExistingFile(Path p, ConcurrentMap useTokens) { - return new UseToken(p, useTokens, ActivationType.UPDATE); + public static UseToken createWithExistingFile(Path p, String owner, ConcurrentMap useTokens) { + return new UseToken(p, owner, useTokens, ActivationType.UPDATE); } - public static UseToken createWithExistingInvalidFile(Path p, ConcurrentMap useTokens) { - return new UseToken(p, useTokens, ActivationType.STEAL); + public static UseToken createWithExistingInvalidFile(Path p, String owner, ConcurrentMap useTokens) { + return new UseToken(p, owner, useTokens, ActivationType.STEAL); + } + + public static UseToken createInvalid(Path p, ConcurrentMap useTokens) { + return new UseToken(p, "unused", useTokens, ActivationType.NONE); } private static final Logger LOG = LoggerFactory.getLogger(UseToken.class); + private final String owner; private final CompletableFuture creationTask; private final ConcurrentMap useTokens; private final ReentrantReadWriteLock.WriteLock fileCreationSync = new ReentrantReadWriteLock().writeLock(); private volatile Path filePath; private volatile SeekableByteChannel channel; - private volatile boolean closed = false; + private volatile boolean closed; - private UseToken(Path filePath, ConcurrentMap useTokens, ActivationType m) { + private UseToken(Path filePath, String owner, ConcurrentMap useTokens, ActivationType m) { + this.owner = owner; this.filePath = filePath; this.useTokens = useTokens; FileOperation method = switch (m) { case STEAL -> this::stealInUseFile; case UPDATE -> this::updateInUseFile; case CREATE -> this::createInUseFile; + case NONE -> () -> {}; }; - this.creationTask = CompletableFuture.runAsync(() -> { - try { - fileCreationSync.lock(); - if (closed) { - return; + if (m == ActivationType.NONE) { + this.closed = true; + this.creationTask = CompletableFuture.completedFuture(null); + } else { + this.closed = false; + this.creationTask = CompletableFuture.runAsync(() -> { + try { + fileCreationSync.lock(); + if (closed) { + return; + } + //Do critical stuff + method.execute(); + } catch (IOException e) { + close(); + } finally { + fileCreationSync.unlock(); } - //Do critical stuff - method.execute(); - } catch (IOException e) { - close(); - } finally { - fileCreationSync.unlock(); - } - }, CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS, Executors.newVirtualThreadPerTaskExecutor())); + }, CompletableFuture.delayedExecutor(Constants.IN_USE_DELAY_MILLIS, TimeUnit.MILLISECONDS, Executors.newVirtualThreadPerTaskExecutor())); + } + } private void stealInUseFile() throws IOException { @@ -103,8 +130,8 @@ void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOEx this.channel = Files.newByteChannel(inUseFilePath, openOptions); var rawInfo = new ByteArrayOutputStream(4_000); var prop = new Properties(); - prop.put("owner", "owner"); //TODO: add real info - prop.put("owningSince", Instant.now().toString()); + prop.put("owner", owner); + prop.put("since", Instant.now().toString()); prop.store(rawInfo, "UNENCRYPTED Cryptomator inUse file"); //TODO: encryption channel.write(ByteBuffer.wrap(rawInfo.toByteArray())); @@ -174,7 +201,8 @@ public void close() { enum ActivationType { UPDATE, CREATE, - STEAL; + STEAL, + NONE; } @FunctionalInterface diff --git a/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java index 5887bfcd..a2310e58 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java @@ -1,6 +1,7 @@ package org.cryptomator.cryptofs.fh; import org.awaitility.Awaitility; +import org.cryptomator.cryptofs.common.Constants; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; @@ -15,9 +16,9 @@ import java.nio.file.Path; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchService; +import java.time.Duration; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.TimeUnit; public class UseTokenTest { @@ -26,6 +27,9 @@ public class UseTokenTest { Path tmpDir; private WatchService watchService; + private final static Duration FILE_OPERATION_DELAY = Duration.ofMillis(Constants.IN_USE_DELAY_MILLIS - 100); + private final static Duration FILE_OPERATION_MAX = FILE_OPERATION_DELAY.plusMillis(3000); + @BeforeEach public void beforeEach() throws IOException { useTokens = new ConcurrentHashMap<>();//Mockito.mock(ConcurrentMap.class); @@ -45,8 +49,8 @@ public void afterEach() { @DisplayName("After 5 seconds of token creation, a new file is created") public void testFileCreation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); - try (var token = UseToken.createWithNewFile(filePath, useTokens)) { - Awaitility.await().atLeast(5, TimeUnit.SECONDS).atMost(8, TimeUnit.SECONDS).until(() -> Files.exists(filePath)); + try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { + Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); Assertions.assertTrue(Files.exists(filePath)); } Assertions.assertTrue(Files.notExists(filePath)); @@ -60,8 +64,8 @@ public void testFileSteal() throws IOException { var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); var fileTime = Files.getLastModifiedTime(filePath); - try (var token = UseToken.createWithExistingInvalidFile(filePath, useTokens)) { - Awaitility.await().atLeast(5, TimeUnit.SECONDS).atMost(8, TimeUnit.SECONDS).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); + try (var token = UseToken.createWithExistingInvalidFile(filePath, "test3000", useTokens)) { + Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); var events = watchKey.pollEvents(); var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); Assertions.assertTrue(createEvent.isPresent()); @@ -74,14 +78,29 @@ public void testFileSteal() throws IOException { Assertions.assertNull(useTokens.get(filePath)); } + @Test + @DisplayName("Invalid token creation does nothing") + public void testInvalid() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); + + try (var token = UseToken.createInvalid(filePath, useTokens)) { + Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); + Assertions.assertTrue(Files.notExists(filePath)); + Assertions.assertTrue(token.isClosed()); + Assertions.assertNull(useTokens.get(filePath)); + MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); + } + } + @Test @DisplayName("After 5 seconds of token creation, failed steal closes the token ") public void testFileStealFails() throws IOException { var filePath = tmpDir.resolve("inUse.file"); //file does not exist var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = UseToken.createWithExistingInvalidFile(filePath, useTokens)) { - Awaitility.await().atLeast(4950, TimeUnit.MILLISECONDS).atMost(10, TimeUnit.SECONDS).until(token::isClosed); + try (var token = UseToken.createWithExistingInvalidFile(filePath, "test3000", useTokens)) { + Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(token::isClosed); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); Assertions.assertNull(useTokens.get(filePath)); @@ -95,10 +114,10 @@ public void testTokenCloseBeforeFileOperation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = UseToken.createWithNewFile(filePath, useTokens)) { + try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { Assertions.assertTrue(Files.notExists(filePath)); } - Awaitility.await().pollDelay(7, TimeUnit.SECONDS).timeout(10, TimeUnit.SECONDS).until(() -> true); + Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertNull(useTokens.get(filePath)); MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); @@ -111,13 +130,13 @@ public void testMoveBefore() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = UseToken.createWithNewFile(filePath, useTokens)) { + try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { token.move(targetPath); //no file operation after move MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); // target file will be created - Awaitility.await().atLeast(5000, TimeUnit.MILLISECONDS).atMost(10, TimeUnit.SECONDS).until(() -> Files.exists(targetPath)); + Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(targetPath)); //orginal filePath does not exist, target exists Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(Files.exists(targetPath)); @@ -141,8 +160,8 @@ public void testMoveAfter() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var targetPath = tmpDir.resolve("inUse2.file"); - try (var token = UseToken.createWithNewFile(filePath, useTokens)) { - Awaitility.await().atLeast(4950, TimeUnit.MILLISECONDS).atMost(10, TimeUnit.SECONDS).until(() -> Files.exists(filePath)); + try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { + Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); token.move(targetPath); @@ -164,9 +183,9 @@ public void testMoveClosed() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = UseToken.createWithNewFile(filePath, useTokens)) { + try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { token.close(); - Awaitility.await().pollDelay(7, TimeUnit.SECONDS).timeout(10, TimeUnit.SECONDS).until(() -> true); + Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); token.move(targetPath); From ce1ecd916ad64b826ba12115558d571455972eb3 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 27 Aug 2025 16:34:18 +0200 Subject: [PATCH 27/95] decouple inUseManager from OpenCryptoFile --- src/main/java/module-info.java | 1 + .../cryptofs/CryptoFileSystemModule.java | 15 +++ .../cryptofs/fh/OpenCryptoFile.java | 24 ++-- .../cryptofs/inuse/IgnoringInUseManager.java | 117 +++++++++++++++++ .../cryptofs/inuse/InUseManager.java | 15 +++ .../cryptofs/inuse/RealInUseManager.java | 120 ++++++++++++++++++ 6 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/inuse/IgnoringInUseManager.java create mode 100644 src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java create mode 100644 src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 2e81cc8d..706fd7a0 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -21,6 +21,7 @@ requires static javax.inject; requires jakarta.inject; requires java.compiler; + requires org.jspecify; exports org.cryptomator.cryptofs; exports org.cryptomator.cryptofs.event; diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index 143f465f..22d09d87 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -11,7 +11,10 @@ import org.cryptomator.cryptofs.attr.AttributeViewComponent; import org.cryptomator.cryptofs.dir.DirectoryStreamComponent; import org.cryptomator.cryptofs.event.FilesystemEvent; +import org.cryptomator.cryptofs.inuse.IgnoringInUseManager; +import org.cryptomator.cryptofs.inuse.InUseManager; import org.cryptomator.cryptofs.fh.OpenCryptoFileComponent; +import org.cryptomator.cryptofs.inuse.RealInUseManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,4 +53,16 @@ public Consumer provideFilesystemEventConsumer(CryptoFileSystem } }; } + + @Provides + @CryptoFileSystemScoped + public InUseManager provideInUseManager(CryptoFileSystemProperties fsProps) { + var owner = (String) fsProps.get("owner"); //TODO + if(owner != null) { + return new RealInUseManager(owner); + } else { + return new IgnoringInUseManager(); + } + + } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 81afcdf6..a99955f4 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -3,6 +3,7 @@ import jakarta.inject.Inject; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.ch.CleartextFileChannel; +import org.cryptomator.cryptofs.inuse.RealInUseManager; import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,7 +27,7 @@ public class OpenCryptoFile implements Closeable { private final FileCloseListener listener; private final AtomicReference lastModified; - private final InUseFile inUseFile; + private final RealInUseManager inUseManager; private final Cryptor cryptor; private final FileHeaderHolder headerHolder; private final ChunkIO chunkIO; @@ -35,12 +36,13 @@ public class OpenCryptoFile implements Closeable { private final OpenCryptoFileComponent component; private final AtomicInteger openChannelsCount = new AtomicInteger(0); + private volatile UseToken useToken; @Inject public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHolder headerHolder, ChunkIO chunkIO, // @CurrentOpenFilePath AtomicReference currentFilePath, @OpenFileSize AtomicLong fileSize, // @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component, // - InUseFile inUseFile) { + RealInUseManager inUseManager) { this.listener = listener; this.cryptor = cryptor; this.headerHolder = headerHolder; @@ -49,7 +51,7 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol this.fileSize = fileSize; this.component = component; this.lastModified = lastModified; - this.inUseFile = inUseFile; + this.inUseManager = inUseManager; } /** @@ -67,11 +69,11 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, boo FileChannel ciphertextFileChannel = null; CleartextFileChannel cleartextFileChannel = null; - var openChannels = openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number + openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { //TODO: what about read-only file channels? Then we need to update logic, that first writable channel needs to create this file - if (openChannels == 1 && !skipUsageCheck) { - inUseFile.acquire(); + if (useToken == null || useToken.isClosed()) { + useToken = inUseManager.use(path); } ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); initFileHeader(options, ciphertextFileChannel); @@ -174,9 +176,9 @@ public Path getCurrentFilePath() { * @param newFilePath new ciphertext path */ public void updateCurrentFilePath(Path newFilePath) { - var oldPath = currentFilePath.getAndUpdate(p -> p == null ? null : newFilePath); - if (newFilePath != null) { - inUseFile.move(oldPath); + currentFilePath.getAndUpdate(p -> p == null ? null : newFilePath); + if (newFilePath != null && useToken != null) { + useToken.move(newFilePath); } //else file got deleted and the in-use-file will be deleted in {@link CryptoFileSystem#delete} } @@ -194,8 +196,10 @@ private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChann public void close() { var p = currentFilePath.get(); if (p != null) { + if( useToken != null) { + useToken.close(); + } listener.close(p, this); - inUseFile.close(); } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/IgnoringInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/IgnoringInUseManager.java new file mode 100644 index 00000000..70668a03 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/inuse/IgnoringInUseManager.java @@ -0,0 +1,117 @@ +package org.cryptomator.cryptofs.inuse; + +import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptofs.fh.UseToken; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; +import java.util.function.BiFunction; + +public class IgnoringInUseManager implements InUseManager { + + private static final ConcurrentMap useTokens = new FakeConcurrentMap(); + + @Override + public boolean isInUse(Path ciphertextPath) throws IOException, IllegalArgumentException { + return false; + } + + @Override + public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { + return UseToken.createInvalid(ciphertextPath, useTokens); + } + + private static class FakeConcurrentMap implements ConcurrentMap { + + @Override + public UseToken compute(Path key, + BiFunction remappingFunction) { + return null; + } + + @Override + public int size() { + return 0; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean containsKey(Object key) { + return false; + } + + @Override + public boolean containsValue(Object value) { + return false; + } + + @Override + public UseToken get(Object key) { + return null; + } + + @Override + public UseToken put(Path key, UseToken value) { + return null; + } + + @Override + public UseToken remove(Object key) { + return null; + } + + @Override + public void putAll(Map m) { + + } + + @Override + public void clear() { + + } + + @Override + public Set keySet() { + return Set.of(); + } + + @Override + public Collection values() { + return List.of(); + } + + @Override + public Set> entrySet() { + return Set.of(); + } + + @Override + public UseToken putIfAbsent(Path key, UseToken value) { + return null; + } + + @Override + public boolean remove(Object key, Object value) { + return false; + } + + @Override + public boolean replace(Path key, UseToken oldValue, UseToken newValue) { + return false; + } + + @Override + public UseToken replace(Path key, UseToken value) { + return null; + } + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java new file mode 100644 index 00000000..4cdf75f6 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -0,0 +1,15 @@ +package org.cryptomator.cryptofs.inuse; + +import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptofs.fh.UseToken; + +import java.io.IOException; +import java.nio.file.Path; + +public interface InUseManager { + + boolean isInUse(Path ciphertextPath) throws IOException, IllegalArgumentException; + + UseToken use(Path ciphertextPath) throws FileAlreadyInUseException; + +} diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java new file mode 100644 index 00000000..8fd66455 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -0,0 +1,120 @@ +package org.cryptomator.cryptofs.inuse; + +import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.common.FileTooBigException; +import org.cryptomator.cryptofs.common.FileUtil; +import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptofs.fh.UseToken; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Management object for the in-use-state of encrypted files. + * + * You can just check, if a file is in use with {@link #isInUse(Path)} or try to mark a file as in-use by this crypto filesystem with {@link #use(Path)} + * + * The persistence file of a token has the {@value Constants#INUSE_FILE_SUFFIX} file extension. + */ +public class RealInUseManager implements InUseManager { + + private static final Logger LOG = LoggerFactory.getLogger(RealInUseManager.class); + + private final ConcurrentMap useTokens = new ConcurrentHashMap<>(); + private final String owner; + + public RealInUseManager(@NonNull String owner) { + this.owner = owner; + } + + /** + * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running crypto filesystem. + * + * @param inUseFilePath + * @return {@code true} if the in-use-file exists, but owned by different user + * @throws IOException if the in-use-file does not exist or cannot be read + * @throws IllegalArgumentException if the in-use-file is invalid + */ + @Override + public boolean isInUse(Path inUseFilePath) throws IOException, IllegalArgumentException { + Properties content = readInUseFile(inUseFilePath); + if (!content.get("owner").equals(owner)) { + //TODO: check also timestamps + return true; + } + return false; + } + + Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { + //TODO: decryption + var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); + //TODO: convert to JSON an extract info + // for now we use properties + var props = new Properties(); + try (var stream = new ByteArrayInputStream(bytes)) { + props.load(stream); + validate(props); + return props; + } + } + + private void validate(Properties content) throws IllegalArgumentException { + if (!content.containsKey("owner")) { + throw new IllegalArgumentException("Invalid in-use-file. Missing key \"owner\""); + } + //TODO: more keys + } + + @Override + public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { + try { + return useTokens.computeIfAbsent(ciphertextPath, this::createInternal); + } catch (UncheckedIOException e) { + if (e.getCause() instanceof FileAlreadyInUseException inUseExc) { + throw inUseExc; + } + + throw new IllegalStateException("Expected %s, but got:".formatted(FileAlreadyInUseException.class.getSimpleName()), e); + } + } + + UseToken createInternal(Path ciphertextPath) throws UncheckedIOException { + var inUseFilePath = computeInUseFilePath(ciphertextPath); + try { + if (isInUse(inUseFilePath)) { //TODO: return also filechannel + throw new FileAlreadyInUseException(ciphertextPath); + } + return UseToken.createWithExistingFile(inUseFilePath, owner, useTokens); + } catch (FileAlreadyInUseException e) { + throw new UncheckedIOException(e); //wrapped due to Map::compute method + } catch (NoSuchFileException e) { + LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); + return UseToken.createWithNewFile(inUseFilePath, owner, useTokens); + } catch (FileTooBigException | IllegalArgumentException e) { + LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); + return UseToken.createWithExistingInvalidFile(inUseFilePath, owner, useTokens); + } catch (IOException e) { + LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); + return UseToken.createInvalid(inUseFilePath, useTokens); + } + } + + /** + * @param p a path with a filename ending with {@value Constants#CRYPTOMATOR_FILE_SUFFIX} + * @return a sibling path with the file extension {@value Constants#INUSE_FILE_SUFFIX} + */ + static Path computeInUseFilePath(Path p) { + var tmp = p.getFileName().toString(); + var fileName = tmp.substring(0, tmp.length() - Constants.CRYPTOMATOR_FILE_SUFFIX.length()); + return p.resolveSibling(fileName + Constants.INUSE_FILE_SUFFIX); + } +} From fb3cf20caaadb8c1a84d294ea0d339d41f046d6c Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Sun, 31 Aug 2025 11:26:41 +0200 Subject: [PATCH 28/95] Refactor inUseManager * create interface * sublass interface by "fake" impl and real impl * decide on cryptofilesystem proeprties what impl to use * create interface UseToken * subclass it with real and stub impl --- .../cryptofs/CryptoFileSystemModule.java | 4 +- .../cryptofs/fh/OpenCryptoFile.java | 21 ++-- .../cryptofs/inuse/IgnoringInUseManager.java | 117 ------------------ .../cryptofs/inuse/InUseManager.java | 6 +- .../cryptofs/inuse/RealInUseManager.java | 33 ++--- .../UseToken.java => inuse/RealUseToken.java} | 33 ++--- .../cryptofs/inuse/StubInUseManager.java | 19 +++ .../cryptomator/cryptofs/inuse/UseToken.java | 20 +++ .../RealUseTokenTest.java} | 34 ++--- 9 files changed, 108 insertions(+), 179 deletions(-) delete mode 100644 src/main/java/org/cryptomator/cryptofs/inuse/IgnoringInUseManager.java rename src/main/java/org/cryptomator/cryptofs/{fh/UseToken.java => inuse/RealUseToken.java} (81%) create mode 100644 src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java create mode 100644 src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java rename src/test/java/org/cryptomator/cryptofs/{fh/UseTokenTest.java => inuse/RealUseTokenTest.java} (87%) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index 22d09d87..4bab98c2 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -11,7 +11,7 @@ import org.cryptomator.cryptofs.attr.AttributeViewComponent; import org.cryptomator.cryptofs.dir.DirectoryStreamComponent; import org.cryptomator.cryptofs.event.FilesystemEvent; -import org.cryptomator.cryptofs.inuse.IgnoringInUseManager; +import org.cryptomator.cryptofs.inuse.StubInUseManager; import org.cryptomator.cryptofs.inuse.InUseManager; import org.cryptomator.cryptofs.fh.OpenCryptoFileComponent; import org.cryptomator.cryptofs.inuse.RealInUseManager; @@ -61,7 +61,7 @@ public InUseManager provideInUseManager(CryptoFileSystemProperties fsProps) { if(owner != null) { return new RealInUseManager(owner); } else { - return new IgnoringInUseManager(); + return new StubInUseManager(); } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index a99955f4..e98cf2d5 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -3,7 +3,8 @@ import jakarta.inject.Inject; import org.cryptomator.cryptofs.EffectiveOpenOptions; import org.cryptomator.cryptofs.ch.CleartextFileChannel; -import org.cryptomator.cryptofs.inuse.RealInUseManager; +import org.cryptomator.cryptofs.inuse.InUseManager; +import org.cryptomator.cryptofs.inuse.UseToken; import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,7 +28,7 @@ public class OpenCryptoFile implements Closeable { private final FileCloseListener listener; private final AtomicReference lastModified; - private final RealInUseManager inUseManager; + private final InUseManager inUseManager; private final Cryptor cryptor; private final FileHeaderHolder headerHolder; private final ChunkIO chunkIO; @@ -42,7 +43,7 @@ public class OpenCryptoFile implements Closeable { public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHolder headerHolder, ChunkIO chunkIO, // @CurrentOpenFilePath AtomicReference currentFilePath, @OpenFileSize AtomicLong fileSize, // @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component, // - RealInUseManager inUseManager) { + InUseManager inUseManager) { this.listener = listener; this.cryptor = cryptor; this.headerHolder = headerHolder; @@ -52,6 +53,7 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol this.component = component; this.lastModified = lastModified; this.inUseManager = inUseManager; + this.useToken = UseToken.INIT_TOKEN; } /** @@ -72,9 +74,10 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, boo openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { //TODO: what about read-only file channels? Then we need to update logic, that first writable channel needs to create this file - if (useToken == null || useToken.isClosed()) { - useToken = inUseManager.use(path); + if( useToken instanceof UseToken.InitToken) { + //just an idea } + useToken = inUseManager.use(path); //TODO: performance, because this causes a hashmap access ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); initFileHeader(options, ciphertextFileChannel); initFileSize(ciphertextFileChannel); @@ -177,8 +180,8 @@ public Path getCurrentFilePath() { */ public void updateCurrentFilePath(Path newFilePath) { currentFilePath.getAndUpdate(p -> p == null ? null : newFilePath); - if (newFilePath != null && useToken != null) { - useToken.move(newFilePath); + if (newFilePath != null) { + useToken.moveTo(newFilePath); } //else file got deleted and the in-use-file will be deleted in {@link CryptoFileSystem#delete} } @@ -196,9 +199,7 @@ private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChann public void close() { var p = currentFilePath.get(); if (p != null) { - if( useToken != null) { - useToken.close(); - } + useToken.close(); listener.close(p, this); } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/IgnoringInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/IgnoringInUseManager.java deleted file mode 100644 index 70668a03..00000000 --- a/src/main/java/org/cryptomator/cryptofs/inuse/IgnoringInUseManager.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.cryptomator.cryptofs.inuse; - -import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; -import org.cryptomator.cryptofs.fh.UseToken; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentMap; -import java.util.function.BiFunction; - -public class IgnoringInUseManager implements InUseManager { - - private static final ConcurrentMap useTokens = new FakeConcurrentMap(); - - @Override - public boolean isInUse(Path ciphertextPath) throws IOException, IllegalArgumentException { - return false; - } - - @Override - public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { - return UseToken.createInvalid(ciphertextPath, useTokens); - } - - private static class FakeConcurrentMap implements ConcurrentMap { - - @Override - public UseToken compute(Path key, - BiFunction remappingFunction) { - return null; - } - - @Override - public int size() { - return 0; - } - - @Override - public boolean isEmpty() { - return false; - } - - @Override - public boolean containsKey(Object key) { - return false; - } - - @Override - public boolean containsValue(Object value) { - return false; - } - - @Override - public UseToken get(Object key) { - return null; - } - - @Override - public UseToken put(Path key, UseToken value) { - return null; - } - - @Override - public UseToken remove(Object key) { - return null; - } - - @Override - public void putAll(Map m) { - - } - - @Override - public void clear() { - - } - - @Override - public Set keySet() { - return Set.of(); - } - - @Override - public Collection values() { - return List.of(); - } - - @Override - public Set> entrySet() { - return Set.of(); - } - - @Override - public UseToken putIfAbsent(Path key, UseToken value) { - return null; - } - - @Override - public boolean remove(Object key, Object value) { - return false; - } - - @Override - public boolean replace(Path key, UseToken oldValue, UseToken newValue) { - return false; - } - - @Override - public UseToken replace(Path key, UseToken value) { - return null; - } - } -} diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index 4cdf75f6..f60d70ad 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -1,14 +1,16 @@ package org.cryptomator.cryptofs.inuse; import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; -import org.cryptomator.cryptofs.fh.UseToken; import java.io.IOException; import java.nio.file.Path; +/** + * Factory for + */ public interface InUseManager { - boolean isInUse(Path ciphertextPath) throws IOException, IllegalArgumentException; + boolean isInUseByOthers(Path ciphertextPath) throws IOException, IllegalArgumentException; UseToken use(Path ciphertextPath) throws FileAlreadyInUseException; diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 8fd66455..c15c81b9 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -4,7 +4,6 @@ import org.cryptomator.cryptofs.common.FileTooBigException; import org.cryptomator.cryptofs.common.FileUtil; import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; -import org.cryptomator.cryptofs.fh.UseToken; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,16 +19,16 @@ /** * Management object for the in-use-state of encrypted files. - * - * You can just check, if a file is in use with {@link #isInUse(Path)} or try to mark a file as in-use by this crypto filesystem with {@link #use(Path)} - * + *

    + * You can just check, if a file is in use with {@link #isInUseByOthers(Path)} or try to mark a file as in-use by this crypto filesystem with {@link #use(Path)} + *

    * The persistence file of a token has the {@value Constants#INUSE_FILE_SUFFIX} file extension. */ public class RealInUseManager implements InUseManager { private static final Logger LOG = LoggerFactory.getLogger(RealInUseManager.class); - private final ConcurrentMap useTokens = new ConcurrentHashMap<>(); + private final ConcurrentMap useTokens = new ConcurrentHashMap<>(); private final String owner; public RealInUseManager(@NonNull String owner) { @@ -39,14 +38,18 @@ public RealInUseManager(@NonNull String owner) { /** * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running crypto filesystem. * - * @param inUseFilePath + * @param ciphertextPath * @return {@code true} if the in-use-file exists, but owned by different user * @throws IOException if the in-use-file does not exist or cannot be read * @throws IllegalArgumentException if the in-use-file is invalid */ @Override - public boolean isInUse(Path inUseFilePath) throws IOException, IllegalArgumentException { - Properties content = readInUseFile(inUseFilePath); + public boolean isInUseByOthers(Path ciphertextPath) throws IOException, IllegalArgumentException { + if(useTokens.containsKey(ciphertextPath)) { + return false; + } + + Properties content = readInUseFile(computeInUseFilePath(ciphertextPath)); if (!content.get("owner").equals(owner)) { //TODO: check also timestamps return true; @@ -87,24 +90,24 @@ public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { } } - UseToken createInternal(Path ciphertextPath) throws UncheckedIOException { + RealUseToken createInternal(Path ciphertextPath) throws UncheckedIOException { var inUseFilePath = computeInUseFilePath(ciphertextPath); try { - if (isInUse(inUseFilePath)) { //TODO: return also filechannel + if (isInUseByOthers(inUseFilePath)) { //TODO: return also filechannel throw new FileAlreadyInUseException(ciphertextPath); } - return UseToken.createWithExistingFile(inUseFilePath, owner, useTokens); + return RealUseToken.createWithExistingFile(inUseFilePath, owner, useTokens); } catch (FileAlreadyInUseException e) { throw new UncheckedIOException(e); //wrapped due to Map::compute method } catch (NoSuchFileException e) { LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); - return UseToken.createWithNewFile(inUseFilePath, owner, useTokens); + return RealUseToken.createWithNewFile(inUseFilePath, owner, useTokens); } catch (FileTooBigException | IllegalArgumentException e) { LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); - return UseToken.createWithExistingInvalidFile(inUseFilePath, owner, useTokens); - } catch (IOException e) { + return RealUseToken.createWithExistingInvalidFile(inUseFilePath, owner, useTokens); + } catch (IOException e) { //TODO: check if we need to pt the token into the map LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); - return UseToken.createInvalid(inUseFilePath, useTokens); + return RealUseToken.createInvalid(inUseFilePath, useTokens); } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java similarity index 81% rename from src/main/java/org/cryptomator/cryptofs/fh/UseToken.java rename to src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index f20c4121..1c98af64 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/UseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -1,11 +1,10 @@ -package org.cryptomator.cryptofs.fh; +package org.cryptomator.cryptofs.inuse; import org.cryptomator.cryptofs.common.Constants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; -import java.io.Closeable; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.ByteBuffer; @@ -36,36 +35,36 @@ * The JSON data is encrypted with the vault masterkey. * If the token is closed before the persistence started, the persistence is not performed. */ -public class UseToken implements Closeable { +public final class RealUseToken implements UseToken { - public static UseToken createWithNewFile(Path p, String owner, ConcurrentMap useTokens) { - return new UseToken(p, owner, useTokens, ActivationType.CREATE); + public static RealUseToken createWithNewFile(Path p, String owner, ConcurrentMap useTokens) { + return new RealUseToken(p, owner, useTokens, ActivationType.CREATE); } - public static UseToken createWithExistingFile(Path p, String owner, ConcurrentMap useTokens) { - return new UseToken(p, owner, useTokens, ActivationType.UPDATE); + public static RealUseToken createWithExistingFile(Path p, String owner, ConcurrentMap useTokens) { + return new RealUseToken(p, owner, useTokens, ActivationType.UPDATE); } - public static UseToken createWithExistingInvalidFile(Path p, String owner, ConcurrentMap useTokens) { - return new UseToken(p, owner, useTokens, ActivationType.STEAL); + public static RealUseToken createWithExistingInvalidFile(Path p, String owner, ConcurrentMap useTokens) { + return new RealUseToken(p, owner, useTokens, ActivationType.STEAL); } - public static UseToken createInvalid(Path p, ConcurrentMap useTokens) { - return new UseToken(p, "unused", useTokens, ActivationType.NONE); + public static RealUseToken createInvalid(Path p, ConcurrentMap useTokens) { + return new RealUseToken(p, "unused", useTokens, ActivationType.NONE); } - private static final Logger LOG = LoggerFactory.getLogger(UseToken.class); + private static final Logger LOG = LoggerFactory.getLogger(RealUseToken.class); private final String owner; private final CompletableFuture creationTask; - private final ConcurrentMap useTokens; + private final ConcurrentMap useTokens; private final ReentrantReadWriteLock.WriteLock fileCreationSync = new ReentrantReadWriteLock().writeLock(); private volatile Path filePath; private volatile SeekableByteChannel channel; private volatile boolean closed; - private UseToken(Path filePath, String owner, ConcurrentMap useTokens, ActivationType m) { + private RealUseToken(Path filePath, String owner, ConcurrentMap useTokens, ActivationType m) { this.owner = owner; this.filePath = filePath; this.useTokens = useTokens; @@ -138,7 +137,8 @@ void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOEx channel.position(0); } - void move(Path newPath) { + @Override + public void moveTo(Path newPath) { try { //sync with file creation fileCreationSync.lock(); @@ -166,7 +166,8 @@ void move(Path newPath) { } } - boolean isClosed() { + @Override + public boolean isClosed() { return closed; } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java new file mode 100644 index 00000000..58337dd3 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java @@ -0,0 +1,19 @@ +package org.cryptomator.cryptofs.inuse; + +import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; + +import java.io.IOException; +import java.nio.file.Path; + +public class StubInUseManager implements InUseManager { + + @Override + public boolean isInUseByOthers(Path ciphertextPath) throws IOException, IllegalArgumentException { + return false; + } + + @Override + public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { + return UseToken.INIT_TOKEN; + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java new file mode 100644 index 00000000..5bc789a3 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java @@ -0,0 +1,20 @@ +package org.cryptomator.cryptofs.inuse; + +import java.io.Closeable; +import java.nio.file.Path; + +public sealed interface UseToken extends Closeable permits RealUseToken, UseToken.InitToken { + + UseToken INIT_TOKEN = new InitToken(); + + default void moveTo(Path newPath) {} + + default boolean isClosed() { + return false; + } + + default void close() {} + + final class InitToken implements UseToken {} + +} diff --git a/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java similarity index 87% rename from src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java rename to src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index a2310e58..798adf51 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/UseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -1,4 +1,4 @@ -package org.cryptomator.cryptofs.fh; +package org.cryptomator.cryptofs.inuse; import org.awaitility.Awaitility; import org.cryptomator.cryptofs.common.Constants; @@ -20,9 +20,9 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -public class UseTokenTest { +public class RealUseTokenTest { - private ConcurrentMap useTokens; + private ConcurrentMap useTokens; @TempDir Path tmpDir; private WatchService watchService; @@ -49,7 +49,7 @@ public void afterEach() { @DisplayName("After 5 seconds of token creation, a new file is created") public void testFileCreation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); - try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); Assertions.assertTrue(Files.exists(filePath)); } @@ -64,7 +64,7 @@ public void testFileSteal() throws IOException { var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); var fileTime = Files.getLastModifiedTime(filePath); - try (var token = UseToken.createWithExistingInvalidFile(filePath, "test3000", useTokens)) { + try (var token = RealUseToken.createWithExistingInvalidFile(filePath, "test3000", useTokens)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); var events = watchKey.pollEvents(); var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); @@ -84,7 +84,7 @@ public void testInvalid() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); - try (var token = UseToken.createInvalid(filePath, useTokens)) { + try (var token = RealUseToken.createInvalid(filePath, useTokens)) { Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); @@ -99,7 +99,7 @@ public void testFileStealFails() throws IOException { var filePath = tmpDir.resolve("inUse.file"); //file does not exist var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = UseToken.createWithExistingInvalidFile(filePath, "test3000", useTokens)) { + try (var token = RealUseToken.createWithExistingInvalidFile(filePath, "test3000", useTokens)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(token::isClosed); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); @@ -114,7 +114,7 @@ public void testTokenCloseBeforeFileOperation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { Assertions.assertTrue(Files.notExists(filePath)); } Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); @@ -125,13 +125,13 @@ public void testTokenCloseBeforeFileOperation() throws IOException { @Test @DisplayName("Moving a token before file creation") - public void testMoveBefore() throws IOException { + public void testMoveToBefore() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { - token.move(targetPath); + try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { + token.moveTo(targetPath); //no file operation after move MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); @@ -156,14 +156,14 @@ public void testMoveBefore() throws IOException { @Test @DisplayName("Moving a token after file creation") - public void testMoveAfter() throws IOException { + public void testMoveToAfter() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var targetPath = tmpDir.resolve("inUse2.file"); - try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); - token.move(targetPath); + token.moveTo(targetPath); // target file will be created // orginal filePath does not exist, target exists @@ -178,17 +178,17 @@ public void testMoveAfter() throws IOException { @Test @DisplayName("Moving does nothing on closed token") - public void testMoveClosed() throws IOException { + public void testMoveToClosed() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = UseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { token.close(); Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); - token.move(targetPath); + token.moveTo(targetPath); MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); Assertions.assertNull(useTokens.get(filePath)); From 22a83b16954c708e5930a6049720bf7ed6560c67 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Sun, 31 Aug 2025 11:59:45 +0200 Subject: [PATCH 29/95] refine inUseManager api --- .../cryptofs/CryptoFileSystemImpl.java | 8 ++++-- .../cryptofs/inuse/InUseManager.java | 2 +- .../cryptofs/inuse/RealInUseManager.java | 28 +++++++++++++------ .../cryptofs/inuse/StubInUseManager.java | 2 +- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index dc5c55ff..04e4d5d0 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -24,6 +24,7 @@ import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; import org.cryptomator.cryptofs.fh.InUseFile; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; +import org.cryptomator.cryptofs.inuse.InUseManager; import org.cryptomator.cryptolib.api.Cryptor; import jakarta.inject.Inject; @@ -98,7 +99,7 @@ class CryptoFileSystemImpl extends CryptoFileSystem { private final CiphertextDirectoryDeleter ciphertextDirDeleter; private final ReadonlyFlag readonlyFlag; private final CryptoFileSystemProperties fileSystemProperties; - + private final InUseManager inUseManager; private final CryptoPath rootPath; private final CryptoPath emptyPath; private final FileNameDecryptor fileNameDecryptor; @@ -112,7 +113,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider, DirectoryIdBackup dirIdBackup, // AttributeProvider fileAttributeProvider, AttributeByNameProvider fileAttributeByNameProvider, AttributeViewProvider fileAttributeViewProvider, // OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag, // - CryptoFileSystemProperties fileSystemProperties, FileNameDecryptor fileNameDecryptor, Consumer eventConsumer) { + CryptoFileSystemProperties fileSystemProperties, InUseManager inUseManager, FileNameDecryptor fileNameDecryptor, Consumer eventConsumer) { this.provider = provider; this.cryptoFileSystems = cryptoFileSystems; this.pathToVault = pathToVault; @@ -137,6 +138,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems this.rootPath = cryptoPathFactory.rootFor(this); this.emptyPath = cryptoPathFactory.emptyFor(this); + this.inUseManager = inUseManager; this.fileNameDecryptor = fileNameDecryptor; this.eventConsumer = eventConsumer; } @@ -733,7 +735,7 @@ public String toString() { } private void checkUsage(CryptoPath cleartextPath, CiphertextFilePath ciphertextPath) throws FileAlreadyInUseException { - if (InUseFile.isInUse(ciphertextPath.getFilePath(), "owner")) { ; //TODO: get the filesystemowner + if (inUseManager.isInUseByOthers(ciphertextPath.getFilePath())) { eventConsumer.accept(new FileIsInUseEvent(cleartextPath, ciphertextPath.getRawPath(), new Properties())); //TODO: properties?? throw new FileAlreadyInUseException(ciphertextPath.getRawPath()); } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index f60d70ad..3b140f2c 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -10,7 +10,7 @@ */ public interface InUseManager { - boolean isInUseByOthers(Path ciphertextPath) throws IOException, IllegalArgumentException; + boolean isInUseByOthers(Path ciphertextPath); UseToken use(Path ciphertextPath) throws FileAlreadyInUseException; diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index c15c81b9..a27bb4b8 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -35,21 +35,31 @@ public RealInUseManager(@NonNull String owner) { this.owner = owner; } + + @Override + public boolean isInUseByOthers(Path ciphertextPath) { + if(useTokens.containsKey(ciphertextPath)) { + return false; + } + + try { + var inUseFilePath = computeInUseFilePath(ciphertextPath); + return isInUseInternal(inUseFilePath); + } catch (IllegalArgumentException | IOException e) { + return false; + } + } + /** * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running crypto filesystem. * - * @param ciphertextPath + * @param inUseFilePath * @return {@code true} if the in-use-file exists, but owned by different user * @throws IOException if the in-use-file does not exist or cannot be read * @throws IllegalArgumentException if the in-use-file is invalid */ - @Override - public boolean isInUseByOthers(Path ciphertextPath) throws IOException, IllegalArgumentException { - if(useTokens.containsKey(ciphertextPath)) { - return false; - } - - Properties content = readInUseFile(computeInUseFilePath(ciphertextPath)); + boolean isInUseInternal(Path inUseFilePath) throws IOException, IllegalArgumentException { + Properties content = readInUseFile(inUseFilePath); if (!content.get("owner").equals(owner)) { //TODO: check also timestamps return true; @@ -93,7 +103,7 @@ public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { RealUseToken createInternal(Path ciphertextPath) throws UncheckedIOException { var inUseFilePath = computeInUseFilePath(ciphertextPath); try { - if (isInUseByOthers(inUseFilePath)) { //TODO: return also filechannel + if (isInUseInternal(inUseFilePath)) { //TODO: return also filechannel throw new FileAlreadyInUseException(ciphertextPath); } return RealUseToken.createWithExistingFile(inUseFilePath, owner, useTokens); diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java index 58337dd3..33a99f77 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java @@ -8,7 +8,7 @@ public class StubInUseManager implements InUseManager { @Override - public boolean isInUseByOthers(Path ciphertextPath) throws IOException, IllegalArgumentException { + public boolean isInUseByOthers(Path ciphertextPath) { return false; } From 1a722ea224b846e7a96f9ba003a0e72cf0c1f0dd Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Sun, 31 Aug 2025 12:06:48 +0200 Subject: [PATCH 30/95] fix file deletion problem --- .../cryptomator/cryptofs/CryptoFileSystemImpl.java | 11 +---------- .../org/cryptomator/cryptofs/fh/OpenCryptoFile.java | 5 +++-- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 04e4d5d0..da5dccf2 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -8,6 +8,7 @@ *******************************************************************************/ package org.cryptomator.cryptofs; +import jakarta.inject.Inject; import org.cryptomator.cryptofs.attr.AttributeByNameProvider; import org.cryptomator.cryptofs.attr.AttributeProvider; import org.cryptomator.cryptofs.attr.AttributeViewProvider; @@ -22,12 +23,10 @@ import org.cryptomator.cryptofs.event.FileIsInUseEvent; import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; -import org.cryptomator.cryptofs.fh.InUseFile; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptofs.inuse.InUseManager; import org.cryptomator.cryptolib.api.Cryptor; -import jakarta.inject.Inject; import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.file.AccessDeniedException; @@ -453,14 +452,6 @@ private void deleteFile(CryptoPath cleartextPath, CiphertextFilePath ciphertextP checkUsage(cleartextPath, ciphertextPath); openCryptoFiles.delete(ciphertextPath.getFilePath()); Files.walkFileTree(ciphertextPath.getRawPath(), DeletingFileVisitor.INSTANCE); - if(!ciphertextPath.isShortened()) { - try { - Files.deleteIfExists(InUseFile.computeInUseFilePath(ciphertextPath.getFilePath())); - } catch (IOException e) { - //no-op - //TODO: log as info? - } - } } private void deleteSymlink(CiphertextFilePath ciphertextPath) throws IOException { diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index e98cf2d5..4fb4dfe7 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -74,7 +74,7 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, boo openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { //TODO: what about read-only file channels? Then we need to update logic, that first writable channel needs to create this file - if( useToken instanceof UseToken.InitToken) { + if (useToken instanceof UseToken.InitToken) { //just an idea } useToken = inUseManager.use(path); //TODO: performance, because this causes a hashmap access @@ -182,8 +182,9 @@ public void updateCurrentFilePath(Path newFilePath) { currentFilePath.getAndUpdate(p -> p == null ? null : newFilePath); if (newFilePath != null) { useToken.moveTo(newFilePath); + } else { + useToken.close(); //encrypted file will be deleted, hence we can stop checking usage } - //else file got deleted and the in-use-file will be deleted in {@link CryptoFileSystem#delete} } private synchronized void cleartextChannelClosed(FileChannel ciphertextFileChannel) { From aeb6f6c7ae523c08e7bcb20bbcd94d79545fb24f Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 1 Sep 2025 11:44:20 +0200 Subject: [PATCH 31/95] fix wrong path used in move and close methods --- .../cryptofs/inuse/RealInUseManager.java | 14 +++++++------- .../cryptomator/cryptofs/inuse/RealUseToken.java | 16 +++++++++++----- .../org/cryptomator/cryptofs/inuse/UseToken.java | 2 +- .../cryptofs/inuse/RealUseTokenTest.java | 6 +++--- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index a27bb4b8..101fda70 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -89,8 +89,9 @@ private void validate(Properties content) throws IllegalArgumentException { @Override public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { + var inUseFilePath = computeInUseFilePath(ciphertextPath); try { - return useTokens.computeIfAbsent(ciphertextPath, this::createInternal); + return useTokens.computeIfAbsent(inUseFilePath, this::createInternal); } catch (UncheckedIOException e) { if (e.getCause() instanceof FileAlreadyInUseException inUseExc) { throw inUseExc; @@ -100,23 +101,22 @@ public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { } } - RealUseToken createInternal(Path ciphertextPath) throws UncheckedIOException { - var inUseFilePath = computeInUseFilePath(ciphertextPath); + RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { try { if (isInUseInternal(inUseFilePath)) { //TODO: return also filechannel - throw new FileAlreadyInUseException(ciphertextPath); + throw new FileAlreadyInUseException(inUseFilePath); } return RealUseToken.createWithExistingFile(inUseFilePath, owner, useTokens); } catch (FileAlreadyInUseException e) { throw new UncheckedIOException(e); //wrapped due to Map::compute method } catch (NoSuchFileException e) { - LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); + LOG.debug("No in-use-file {} found. Creating it.", inUseFilePath, e); return RealUseToken.createWithNewFile(inUseFilePath, owner, useTokens); } catch (FileTooBigException | IllegalArgumentException e) { - LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); + LOG.info("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); return RealUseToken.createWithExistingInvalidFile(inUseFilePath, owner, useTokens); } catch (IOException e) { //TODO: check if we need to pt the token into the map - LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); + LOG.warn("Failed to read in-use file {}. Ignoring it.", inUseFilePath, e); return RealUseToken.createInvalid(inUseFilePath, useTokens); } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 1c98af64..5917f1ff 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -138,7 +138,13 @@ void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOEx } @Override - public void moveTo(Path newPath) { + public void moveTo(Path newCiphertextPath) { + var inUseFilePath = RealInUseManager.computeInUseFilePath(newCiphertextPath); + moveToInternal(inUseFilePath); + } + + //visible for testing + void moveToInternal(Path newFilePath) { try { //sync with file creation fileCreationSync.lock(); @@ -146,10 +152,10 @@ public void moveTo(Path newPath) { if (closed) { return; } - useTokens.compute(newPath, (p, t) -> { + useTokens.compute(newFilePath, (p, t) -> { try { if (channel != null) { - Files.move(filePath, newPath, StandardCopyOption.REPLACE_EXISTING); + Files.move(filePath, newFilePath, StandardCopyOption.REPLACE_EXISTING); } return this; } catch (IOException e) { @@ -157,9 +163,9 @@ public void moveTo(Path newPath) { } }); useTokens.remove(filePath); - this.filePath = newPath; + this.filePath = newFilePath; } catch (UncheckedIOException e) { - LOG.warn("Failed to move in-use file {} to {}.", filePath, newPath, e.getCause()); + LOG.warn("Failed to move in-use file {} to {}.", filePath, newFilePath, e.getCause()); close(); //To prevent invalid states } finally { fileCreationSync.unlock(); diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java index 5bc789a3..b9954c85 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java @@ -7,7 +7,7 @@ public sealed interface UseToken extends Closeable permits RealUseToken, UseToke UseToken INIT_TOKEN = new InitToken(); - default void moveTo(Path newPath) {} + default void moveTo(Path newCiphertextPath) {} default boolean isClosed() { return false; diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index 798adf51..b81f13f0 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -131,7 +131,7 @@ public void testMoveToBefore() throws IOException { var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { - token.moveTo(targetPath); + token.moveToInternal(targetPath); //no file operation after move MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); @@ -163,7 +163,7 @@ public void testMoveToAfter() throws IOException { try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); - token.moveTo(targetPath); + token.moveToInternal(targetPath); // target file will be created // orginal filePath does not exist, target exists @@ -188,7 +188,7 @@ public void testMoveToClosed() throws IOException { Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); - token.moveTo(targetPath); + token.moveToInternal(targetPath); MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); Assertions.assertNull(useTokens.get(filePath)); From 2997f041f465f20ffd78b8b34b07b0e1e18950a3 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 1 Sep 2025 13:28:47 +0200 Subject: [PATCH 32/95] add unit tests for RealInUseManager --- .../cryptofs/inuse/RealInUseManager.java | 16 +- .../cryptofs/inuse/RealUseToken.java | 2 +- .../cryptofs/inuse/RealInUseManagerTest.java | 186 ++++++++++++++++++ .../cryptofs/inuse/RealUseTokenTest.java | 4 +- 4 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 101fda70..0a28d163 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -28,22 +28,23 @@ public class RealInUseManager implements InUseManager { private static final Logger LOG = LoggerFactory.getLogger(RealInUseManager.class); - private final ConcurrentMap useTokens = new ConcurrentHashMap<>(); + private final ConcurrentMap useTokens; private final String owner; public RealInUseManager(@NonNull String owner) { this.owner = owner; + this.useTokens = new ConcurrentHashMap<>(); } @Override public boolean isInUseByOthers(Path ciphertextPath) { - if(useTokens.containsKey(ciphertextPath)) { + var inUseFilePath = computeInUseFilePath(ciphertextPath); + if(useTokens.containsKey(inUseFilePath)) { return false; } try { - var inUseFilePath = computeInUseFilePath(ciphertextPath); return isInUseInternal(inUseFilePath); } catch (IllegalArgumentException | IOException e) { return false; @@ -114,7 +115,7 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { return RealUseToken.createWithNewFile(inUseFilePath, owner, useTokens); } catch (FileTooBigException | IllegalArgumentException e) { LOG.info("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); - return RealUseToken.createWithExistingInvalidFile(inUseFilePath, owner, useTokens); + return RealUseToken.createWithInvalidFile(inUseFilePath, owner, useTokens); } catch (IOException e) { //TODO: check if we need to pt the token into the map LOG.warn("Failed to read in-use file {}. Ignoring it.", inUseFilePath, e); return RealUseToken.createInvalid(inUseFilePath, useTokens); @@ -130,4 +131,11 @@ static Path computeInUseFilePath(Path p) { var fileName = tmp.substring(0, tmp.length() - Constants.CRYPTOMATOR_FILE_SUFFIX.length()); return p.resolveSibling(fileName + Constants.INUSE_FILE_SUFFIX); } + + + //for testing + RealInUseManager(String owner, ConcurrentMap useTokens) { + this.owner = owner; + this.useTokens = useTokens; + } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 5917f1ff..227f1854 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -45,7 +45,7 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Concurre return new RealUseToken(p, owner, useTokens, ActivationType.UPDATE); } - public static RealUseToken createWithExistingInvalidFile(Path p, String owner, ConcurrentMap useTokens) { + public static RealUseToken createWithInvalidFile(Path p, String owner, ConcurrentMap useTokens) { return new RealUseToken(p, owner, useTokens, ActivationType.STEAL); } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java new file mode 100644 index 00000000..cdb44519 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -0,0 +1,186 @@ +package org.cryptomator.cryptofs.inuse; + +import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.concurrent.ConcurrentHashMap; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +public class RealInUseManagerTest { + + private MockedStatic staticManagerMock; + private Path ciphertextPath; + private Path inUseFilePath; + + @BeforeEach + public void beforeEach() { + ciphertextPath = mock(Path.class, "ciphertext.c9r"); + inUseFilePath = mock(Path.class, "inUseFile.c9u"); + staticManagerMock = mockStatic(RealInUseManager.class); + staticManagerMock.when(() -> RealInUseManager.computeInUseFilePath(ciphertextPath)).thenReturn(inUseFilePath); + } + + @Test + @DisplayName("If the useTokens map contains the path, return immediately with false") + public void testUseByOthersWithExistingToken() throws IOException { + var preparedMap = new ConcurrentHashMap(); + preparedMap.put(inUseFilePath, mock(RealUseToken.class)); + var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseSpy = spy(inUseManager); + + var result = inUseSpy.isInUseByOthers(ciphertextPath); + + Assertions.assertFalse(result); + verify(inUseSpy, never()).isInUseInternal(inUseFilePath); + } + + @Test + @DisplayName("Call internal inUse check, when map does not contain path") + public void testUseByOthers() throws IOException { + var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseSpy = spy(inUseManager); + doReturn(true).when(inUseSpy).isInUseInternal(inUseFilePath); + + inUseSpy.isInUseByOthers(ciphertextPath); + + verify(inUseSpy).isInUseInternal(inUseFilePath); + } + + @ParameterizedTest + @DisplayName("If internalUse check fails with declared exception, return false") + @ValueSource(classes = {IllegalArgumentException.class, IOException.class}) + public void testUseByOthersException(Class exceptionClass) throws IOException { + var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseSpy = spy(inUseManager); + doThrow(exceptionClass).when(inUseSpy).isInUseInternal(inUseFilePath); + + var result = Assertions.assertDoesNotThrow(() -> inUseSpy.isInUseByOthers(ciphertextPath)); + Assertions.assertFalse(result); + verify(inUseSpy).isInUseInternal(inUseFilePath); + } + + @Test + @DisplayName("\"use\" method places puts path into map and returns token") + public void testUsePlacesPathInMap() throws FileAlreadyInUseException { + var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseSpy = spy(inUseManager); + var token = mock(RealUseToken.class); + + doReturn(token).when(inUseSpy).createInternal(inUseFilePath); + + var result = inUseSpy.use(ciphertextPath); + Assertions.assertSame(token, result); + } + + @Test + @DisplayName("\"use\" method rethrow FileAlreadyInUseException") + public void testUseThrows() throws FileAlreadyInUseException { + var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseSpy = spy(inUseManager); + var inUseException = new FileAlreadyInUseException(inUseFilePath); + + doThrow(new UncheckedIOException(inUseException)).when(inUseSpy).createInternal(inUseFilePath); + + var exception = Assertions.assertThrows(FileAlreadyInUseException.class, () -> inUseSpy.use(ciphertextPath)); + Assertions.assertSame(inUseException, exception); + } + + @Test + @DisplayName("Create internal with existing in-use-file") + public void testCreateExistingValid() throws IOException { + var preparedMap = new ConcurrentHashMap(); + var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseSpy = spy(inUseManager); + var token = mock(RealUseToken.class); + + try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { + staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", preparedMap)).thenReturn(token); + doReturn(false).when(inUseSpy).isInUseInternal(inUseFilePath); + + var result = inUseSpy.createInternal(inUseFilePath); + Assertions.assertSame(token, result); + verify(inUseSpy).isInUseInternal(inUseFilePath); + staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", preparedMap)); + } + } + + @Test + @DisplayName("Create internal with INVALID in-use-file") + public void testCreateExistingInvalid() throws IOException { + var preparedMap = new ConcurrentHashMap(); + var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseSpy = spy(inUseManager); + var token = mock(RealUseToken.class); + + try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { + staticUseTokenMock.when(() -> RealUseToken.createWithInvalidFile(inUseFilePath, "cryptobot3000", preparedMap)).thenReturn(token); + doThrow(IllegalArgumentException.class).when(inUseSpy).isInUseInternal(inUseFilePath); + + var result = inUseSpy.createInternal(inUseFilePath); + Assertions.assertSame(token, result); + verify(inUseSpy).isInUseInternal(inUseFilePath); + staticUseTokenMock.verify(() -> RealUseToken.createWithInvalidFile(inUseFilePath, "cryptobot3000", preparedMap)); + } + } + + @Test + @DisplayName("Create internal with NOT existing in-use-file") + public void testCreateNotExisting() throws IOException { + var preparedMap = new ConcurrentHashMap(); + var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseSpy = spy(inUseManager); + var token = mock(RealUseToken.class); + + try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { + staticUseTokenMock.when(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", preparedMap)).thenReturn(token); + doThrow(NoSuchFileException.class).when(inUseSpy).isInUseInternal(inUseFilePath); + + var result = inUseSpy.createInternal(inUseFilePath); + Assertions.assertSame(token, result); + verify(inUseSpy).isInUseInternal(inUseFilePath); + staticUseTokenMock.verify(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", preparedMap)); + } + } + + @Test + @DisplayName("Create internal throws UncheckedIO(FileAlreadyInUse) exception") + public void testCreateFailedRead() throws IOException { + var preparedMap = new ConcurrentHashMap(); + var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseSpy = spy(inUseManager); + + doReturn(true).when(inUseSpy).isInUseInternal(inUseFilePath); + + var actualException = Assertions.assertThrows(UncheckedIOException.class, () -> inUseSpy.createInternal(inUseFilePath)); + + Assertions.assertInstanceOf(FileAlreadyInUseException.class, actualException.getCause()); + verify(inUseSpy).isInUseInternal(inUseFilePath); + } + + //TODO: test createInvalid + //TODO: test validate + //TODO: test readInUseFile + + @AfterEach + public void afterEach() { + staticManagerMock.close(); + } +} diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index b81f13f0..cc0e1752 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -64,7 +64,7 @@ public void testFileSteal() throws IOException { var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); var fileTime = Files.getLastModifiedTime(filePath); - try (var token = RealUseToken.createWithExistingInvalidFile(filePath, "test3000", useTokens)) { + try (var token = RealUseToken.createWithInvalidFile(filePath, "test3000", useTokens)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); var events = watchKey.pollEvents(); var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); @@ -99,7 +99,7 @@ public void testFileStealFails() throws IOException { var filePath = tmpDir.resolve("inUse.file"); //file does not exist var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = RealUseToken.createWithExistingInvalidFile(filePath, "test3000", useTokens)) { + try (var token = RealUseToken.createWithInvalidFile(filePath, "test3000", useTokens)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(token::isClosed); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); From ac44892a84637a4dac2827fc11af9cbe169b24d7 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 1 Sep 2025 15:45:12 +0200 Subject: [PATCH 33/95] cleanup --- .../cryptofs/fh/InUseFileTest.java | 268 ------------------ 1 file changed, 268 deletions(-) delete mode 100644 src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java diff --git a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java deleted file mode 100644 index 7e455ed7..00000000 --- a/src/test/java/org/cryptomator/cryptofs/fh/InUseFileTest.java +++ /dev/null @@ -1,268 +0,0 @@ -package org.cryptomator.cryptofs.fh; - -import org.cryptomator.cryptofs.common.Constants; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.function.Executable; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.channels.SeekableByteChannel; -import java.nio.file.NoSuchFileException; -import java.nio.file.Path; -import java.util.Properties; -import java.util.concurrent.atomic.AtomicReference; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - - -/* - To test: - * acquire - * readInUseFile - * createInUseFile - * writeInUseFile - * ownInUseFile - * close - * getInUseFilePath - */ -public class InUseFileTest { - - Path ciphertextPath = mock(Path.class, "ciphertextPath"); - AtomicReference currentFilePath = new AtomicReference<>(); - SeekableByteChannel inUseChannel = mock(SeekableByteChannel.class); - Properties info = new Properties(); - InUseFile inUseFile; - - @BeforeEach - public void beforeEach() { - currentFilePath.set(ciphertextPath); - inUseFile = new InUseFile(currentFilePath, "cryptobot", inUseChannel, info); - } - - @Test - @DisplayName("Acquiring existing, valid inUseFile") - public void testAcquireExistingSameOwner() throws IOException { - var inUseFileSpy = spy(inUseFile); - - Path inUsePath = mock(Path.class, "inUseFilePath"); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenReturn(false); - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - - var isInUse = inUseFileSpy.acquire(); - - Assertions.assertTrue(isInUse); - verify(inUseFileSpy, never()).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).stealInUseFile(inUsePath); - //TODO: check, that inUse file is updated - } - } - - @Test - @DisplayName("Acquiring existing, valid inUseFile with different owner throws exception") - public void testAcquireExistingDifferentOwnerThrows() throws IOException { - var inUseFileSpy = spy(inUseFile); - - Path inUsePath = mock(Path.class, "inUseFilePath"); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenReturn(true); - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - - Executable test = () -> inUseFile.acquire(); - - Assertions.assertThrows(FileAlreadyInUseException.class, test); - - verify(inUseFileSpy, never()).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).stealInUseFile(inUsePath); - } - } - - @Test - @DisplayName("Acquire existing, invalid inUseFile steals it") - public void testAcquireReadExistingInvalid() throws IOException { - var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(true).when(inUseFileSpy).stealInUseFile(inUsePath); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenThrow(IllegalArgumentException.class); - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - - var isAcquired = inUseFileSpy.acquire(); - - Assertions.assertTrue(isAcquired); - verify(inUseFileSpy).stealInUseFile(inUsePath); - verify(inUseFileSpy, never()).createInUseFile(inUsePath); - } - } - - @Test - @DisplayName("Acquire existing, invalid inUseFile with failing steal") - public void testAcquireReadExistingInvalidFailedSteal() throws IOException { - var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(false).when(inUseFileSpy).stealInUseFile(inUsePath); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenThrow(IllegalArgumentException.class); - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - when(inUseFileSpy.stealInUseFile(inUsePath)).thenReturn(false); - - var isAcquired = inUseFileSpy.acquire(); - - Assertions.assertFalse(isAcquired); - verify(inUseFileSpy).stealInUseFile(inUsePath); - verify(inUseFileSpy, never()).createInUseFile(inUsePath); - } - } - - @Test - @DisplayName("Acquire not existing inUseFile creates it") - public void testAcquireCreateNew() throws IOException { - var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(true).when(inUseFileSpy).createInUseFile(inUsePath); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenThrow(NoSuchFileException.class); - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - - var isAcquired = inUseFileSpy.acquire(); - - Assertions.assertTrue(isAcquired); - verify(inUseFileSpy).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).stealInUseFile(inUsePath); - } - } - - @Test - @DisplayName("Acquire not existing inUseFile with failing create") - public void testAcquireCreateNewFailing() throws IOException { - var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - doReturn(false).when(inUseFileSpy).createInUseFile(inUsePath); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.isInUseInternal(eq(inUsePath), any())).thenThrow(NoSuchFileException.class); - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - when(inUseFileSpy.createInUseFile(inUsePath)).thenReturn(false); - - var isAcquired = inUseFileSpy.acquire(); - - Assertions.assertFalse(isAcquired); - verify(inUseFileSpy).createInUseFile(inUsePath); - verify(inUseFileSpy, never()).stealInUseFile(inUsePath); - } - } - - - @Test - @DisplayName("Lock files end with .c9u and are in the same directory as the content file") - public void testComputeInUseFilePath(@TempDir Path tmpDir) { - var path = tmpDir.resolve("hello.abc"); - var result = InUseFile.computeInUseFilePath(path); - - Assertions.assertTrue(result.toString().endsWith(Constants.INUSE_FILE_SUFFIX)); - Assertions.assertEquals(path.getParent(), result.getParent()); - - } - - @Test - @DisplayName("Lock files also work with direct root childs") - public void testComputeInUseFilePathWithRoot(@TempDir Path tmpDir) { - var rootChild = tmpDir.getRoot().resolve("test3000.abc"); - - var result = InUseFile.computeInUseFilePath(rootChild); - - Assertions.assertTrue(result.toString().endsWith(Constants.INUSE_FILE_SUFFIX)); - Assertions.assertEquals(rootChild.getParent(), result.getParent()); - } - - @Test - @DisplayName("Close closes inUseFileChannel and removes inUse file") - public void testClose() throws IOException { - var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - doNothing().when(inUseFileSpy).deleteInUseFile(any()); - - inUseFileSpy.close(); - - verify(inUseChannel).close(); - verify(inUseFileSpy).deleteInUseFile(inUsePath); - } - } - - @Test - @DisplayName("Close does not propagate IO exception") - public void testCloseFailing() throws IOException { - var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - - doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); - Assertions.assertDoesNotThrow(inUseFileSpy::close); - - doThrow(IOException.class).when(inUseChannel).close(); - Assertions.assertDoesNotThrow(inUseFileSpy::close); - } - } - - @Test - @DisplayName("Closing after first close() does nothing") - public void testClosedForGood() throws IOException { - var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - doNothing().when(inUseFileSpy).deleteInUseFile(any()); - doNothing().when(inUseChannel).close(); - - Assertions.assertDoesNotThrow(inUseFileSpy::close); - verify(inUseChannel, times(1)).close(); - verify(inUseFileSpy, times(1)).deleteInUseFile(inUsePath); - - Assertions.assertDoesNotThrow(inUseFileSpy::close); - verifyNoMoreInteractions(inUseChannel); - verify(inUseFileSpy, times(1)).deleteInUseFile(inUsePath); - } - } - - @Test - @DisplayName("Closing with failure still marks the file as closed") - public void testClosedForGoodWithFailure() throws IOException { - var inUseFileSpy = spy(inUseFile); - Path inUsePath = mock(Path.class, "inUseFilePath"); - try (var classMock = mockStatic(InUseFile.class)) { - classMock.when(() -> InUseFile.computeInUseFilePath(any())).thenReturn(inUsePath); - doThrow(IOException.class).when(inUseFileSpy).deleteInUseFile(any()); - doThrow(IOException.class).when(inUseChannel).close(); - - Assertions.assertDoesNotThrow(inUseFileSpy::close); - verify(inUseChannel, times(1)).close(); - verify(inUseFileSpy, times(1)).deleteInUseFile(inUsePath); - - Assertions.assertDoesNotThrow(inUseFileSpy::close); - verifyNoMoreInteractions(inUseChannel); - verify(inUseFileSpy, times(1)).deleteInUseFile(inUsePath); - } - } - - @Test - @DisplayName("Closing with null as currentFilePath skips deletion") - public void testCloseWithNullFilePath() throws IOException { - currentFilePath.set(null); - var inUseFileSpy = spy(inUseFile); - try (var classMock = mockStatic(InUseFile.class)) { - doNothing().when(inUseChannel).close(); - - Assertions.assertDoesNotThrow(inUseFileSpy::close); - - verify(inUseFileSpy, never()).deleteInUseFile(any()); - classMock.verify(() -> InUseFile.computeInUseFilePath(any()), never()); - } - } -} From 80a8edb7c8486677927ac91e86e6712837fb2de3 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 1 Sep 2025 15:45:55 +0200 Subject: [PATCH 34/95] if the filesystem is read-only, ignore in-use feature --- .../java/org/cryptomator/cryptofs/CryptoFileSystemModule.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index 4bab98c2..6d9a7da7 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -58,7 +58,7 @@ public Consumer provideFilesystemEventConsumer(CryptoFileSystem @CryptoFileSystemScoped public InUseManager provideInUseManager(CryptoFileSystemProperties fsProps) { var owner = (String) fsProps.get("owner"); //TODO - if(owner != null) { + if(owner != null && !fsProps.readonly()) { return new RealInUseManager(owner); } else { return new StubInUseManager(); From a6d236e406db36c6979b7ee689978a36a1a00784 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 1 Sep 2025 16:19:05 +0200 Subject: [PATCH 35/95] add CLOSED_TOKEN and assign new token based on if old token is closed --- .../cryptofs/fh/OpenCryptoFile.java | 7 +++---- .../cryptofs/inuse/RealInUseManager.java | 19 ++++++++++++++----- .../cryptomator/cryptofs/inuse/UseToken.java | 13 +++++++++++-- .../cryptofs/inuse/RealInUseManagerTest.java | 13 +++++++++++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 4fb4dfe7..69023554 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -53,7 +53,7 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol this.component = component; this.lastModified = lastModified; this.inUseManager = inUseManager; - this.useToken = UseToken.INIT_TOKEN; + this.useToken = UseToken.CLOSED_TOKEN; } /** @@ -74,10 +74,9 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, boo openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { //TODO: what about read-only file channels? Then we need to update logic, that first writable channel needs to create this file - if (useToken instanceof UseToken.InitToken) { - //just an idea + if (useToken.isClosed() ) { //the token was closed prematurely, so we try to get a new one + useToken = inUseManager.use(path); } - useToken = inUseManager.use(path); //TODO: performance, because this causes a hashmap access ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); initFileHeader(options, ciphertextFileChannel); initFileSize(ciphertextFileChannel); diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 0a28d163..82548dd7 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -88,6 +88,13 @@ private void validate(Properties content) throws IllegalArgumentException { //TODO: more keys } + /** + * Marks the given ciphertext path as in-use by this filesystem. + * + * @param ciphertextPath the path to the encrypted file intended to mark as "in-use" + * @return {@link UseToken} for marking ownership. It is either a {@link RealUseToken} on success or {@link UseToken#CLOSED_TOKEN} on failure. + * @throws FileAlreadyInUseException if the file is already used by a different owner + */ @Override public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { var inUseFilePath = computeInUseFilePath(ciphertextPath); @@ -96,15 +103,17 @@ public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { } catch (UncheckedIOException e) { if (e.getCause() instanceof FileAlreadyInUseException inUseExc) { throw inUseExc; + } else { + //any other IOException. Already logged. + return UseToken.CLOSED_TOKEN; } - - throw new IllegalStateException("Expected %s, but got:".formatted(FileAlreadyInUseException.class.getSimpleName()), e); } } RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { try { - if (isInUseInternal(inUseFilePath)) { //TODO: return also filechannel + //TODO: performance idea: cache the result in a short lived cache (e.g. 5 seconds) + if (isInUseInternal(inUseFilePath)) { throw new FileAlreadyInUseException(inUseFilePath); } return RealUseToken.createWithExistingFile(inUseFilePath, owner, useTokens); @@ -116,9 +125,9 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { } catch (FileTooBigException | IllegalArgumentException e) { LOG.info("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); return RealUseToken.createWithInvalidFile(inUseFilePath, owner, useTokens); - } catch (IOException e) { //TODO: check if we need to pt the token into the map + } catch (IOException e) { LOG.warn("Failed to read in-use file {}. Ignoring it.", inUseFilePath, e); - return RealUseToken.createInvalid(inUseFilePath, useTokens); + throw new UncheckedIOException(e); } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java index b9954c85..4c7feea4 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java @@ -3,9 +3,10 @@ import java.io.Closeable; import java.nio.file.Path; -public sealed interface UseToken extends Closeable permits RealUseToken, UseToken.InitToken { +public sealed interface UseToken extends Closeable permits RealUseToken, UseToken.InitToken, UseToken.ClosedToken { UseToken INIT_TOKEN = new InitToken(); + UseToken CLOSED_TOKEN = new ClosedToken(); default void moveTo(Path newCiphertextPath) {} @@ -15,6 +16,14 @@ default boolean isClosed() { default void close() {} - final class InitToken implements UseToken {} + record InitToken() implements UseToken {} + record ClosedToken() implements UseToken { + + @Override + public boolean isClosed() { + return true; + } + + } } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index cdb44519..3173ebca 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -103,6 +103,19 @@ public void testUseThrows() throws FileAlreadyInUseException { Assertions.assertSame(inUseException, exception); } + @Test + @DisplayName("\"use\" method returns CLOSED_TOKEN on IOException") + public void testUseClosedToken() throws FileAlreadyInUseException { + var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseSpy = spy(inUseManager); + var someIOException = new IOException("it's over 9000!"); + + doThrow(new UncheckedIOException(someIOException)).when(inUseSpy).createInternal(inUseFilePath); + + var result = Assertions.assertDoesNotThrow(() -> inUseSpy.use(ciphertextPath)); + Assertions.assertSame(UseToken.CLOSED_TOKEN, result); + } + @Test @DisplayName("Create internal with existing in-use-file") public void testCreateExistingValid() throws IOException { From dd7934206781a4af6cf1a99b60a5f248c3909e25 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 1 Sep 2025 16:20:45 +0200 Subject: [PATCH 36/95] remove unused parameter --- .../cryptofs/CryptoFileSystemImpl.java | 2 +- .../cryptomator/cryptofs/fh/OpenCryptoFile.java | 6 +++--- .../cryptomator/cryptofs/fh/OpenCryptoFiles.java | 4 ++-- .../cryptofs/CryptoFileSystemImplTest.java | 6 +++--- .../cryptofs/fh/OpenCryptoFileTest.java | 16 ++++++++-------- .../cryptofs/fh/OpenCryptoFilesTest.java | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index da5dccf2..893f4492 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -413,7 +413,7 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti FileChannel ch = null; try { - ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options,false, attrs); // might throw FileAlreadyExists + ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options,attrs); // might throw FileAlreadyExists if (options.writable()) { ciphertextPath.persistLongFileName(); stats.incrementAccessesWritten(); diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 69023554..9bdd93ac 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -63,7 +63,7 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol * @return A new file channel. Ideally used in a try-with-resource statement. If the channel is not properly closed, this OpenCryptoFile will stay open indefinite. * @throws IOException */ - public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, boolean skipUsageCheck, FileAttribute... attrs) throws IOException { + public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, FileAttribute... attrs) throws IOException { Path path = currentFilePath.get(); if (path == null) { throw new IllegalStateException("Cannot create file channel to deleted file"); @@ -125,7 +125,7 @@ private void closeQuietly(Closeable closeable) { } /** - * Called by {@link #newFileChannel(EffectiveOpenOptions, boolean, FileAttribute[])} to determine the fileSize. + * Called by {@link #newFileChannel(EffectiveOpenOptions, FileAttribute[])} to determine the fileSize. *

    * Before the size is initialized (i.e. before a channel has been created), {@link #size()} must not be called. *

    @@ -149,7 +149,7 @@ private void initFileSize(FileChannel ciphertextFileChannel) throws IOException } /** - * @return The size of the opened file. Note that the filesize is unknown until a {@link #newFileChannel(EffectiveOpenOptions, boolean, FileAttribute[])} is opened. In this case this method returns an empty optional. + * @return The size of the opened file. Note that the filesize is unknown until a {@link #newFileChannel(EffectiveOpenOptions, FileAttribute[])} is opened. In this case this method returns an empty optional. */ public Optional size() { long val = fileSize.get(); diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java index 46220be2..af9b465b 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFiles.java @@ -64,13 +64,13 @@ public OpenCryptoFile getOrCreate(Path ciphertextPath) { } public void writeCiphertextFile(Path ciphertextPath, EffectiveOpenOptions openOptions, ByteBuffer contents) throws IOException { - try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions, false)) { //TODO: test + try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { //TODO: test 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, false)) { + try (OpenCryptoFile f = getOrCreate(ciphertextPath); FileChannel ch = f.newFileChannel(openOptions)) { if (ch.size() > maxBufferSize) { throw new BufferUnderflowException(); } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 71f9bffd..06fcd4c5 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -457,7 +457,7 @@ public void setup() throws IOException { when(ciphertextPath.getFilePath()).thenReturn(ciphertextFilePath); when(openCryptoFiles.getOrCreate(ciphertextFilePath)).thenReturn(openCryptoFile); when(ciphertextFilePath.getName(3)).thenReturn(mock(CryptoPath.class, "path.c9r")); - when(openCryptoFile.newFileChannel(any(), anyBoolean(), any(FileAttribute[].class))).thenReturn(fileChannel); + when(openCryptoFile.newFileChannel(any(), any(FileAttribute[].class))).thenReturn(fileChannel); } @Nested @@ -511,7 +511,7 @@ public void testNewFileChannelCreate3() throws IOException { FileChannel ch = inTest.newFileChannel(cleartextPath, EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), attrs); Assertions.assertSame(fileChannel, ch); - verify(openCryptoFile).newFileChannel(Mockito.any(), eq(false), Mockito.eq(attrs)); + verify(openCryptoFile).newFileChannel(Mockito.any(), Mockito.eq(attrs)); } } @@ -557,7 +557,7 @@ public void testNewFileChannelReadWriteShortened() throws IOException { @Test @DisplayName("newFileChannel fails if used by another file") public void testNewFileChannelInUseFailure() throws IOException { - when(openCryptoFile.newFileChannel(any(), eq(false))).thenThrow(FileAlreadyInUseException.class); + when(openCryptoFile.newFileChannel(any())).thenThrow(FileAlreadyInUseException.class); Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.newFileChannel(cleartextPath, EnumSet.of(StandardOpenOption.WRITE))); var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index 7ee9c4f1..f50625c4 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -92,7 +92,7 @@ public void testFailedFirstFileChannelImmediatelyCallsClose() throws FileAlready OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { - openCryptoFile.newFileChannel(options, false); + openCryptoFile.newFileChannel(options); }); Assertions.assertSame(expectedException, exception); verify(openCryptoFile).close(); @@ -108,7 +108,7 @@ public void testSkipInUseCheck() throws FileAlreadyInUseException { OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { - openCryptoFile.newFileChannel(options, true); + openCryptoFile.newFileChannel(options); }); Assertions.assertSame(expectedException, exception); verify(openCryptoFile).close(); @@ -123,7 +123,7 @@ public void testInUseFileThrowsException() throws FileAlreadyInUseException { OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); Assertions.assertThrows(FileAlreadyInUseException.class, () -> { - openCryptoFile.newFileChannel(options, false); + openCryptoFile.newFileChannel(options); }); } @@ -146,9 +146,9 @@ public void testFailedSecondFileChannelDoesNothing() throws IOException { Mockito.when(failingOptions.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); - try (var channel = openCryptoFile.newFileChannel(options, false)) { + try (var channel = openCryptoFile.newFileChannel(options)) { UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { - openCryptoFile.newFileChannel(failingOptions, false); + openCryptoFile.newFileChannel(failingOptions); }); Assertions.assertSame(expectedException, exception); verify(openCryptoFile, never()).close(); @@ -171,7 +171,7 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); when(inUseFile.acquire()).thenReturn(true); - openCryptoFile.newFileChannel(options, false); + openCryptoFile.newFileChannel(options); verify(cleartextChannel).truncate(0L); } @@ -319,7 +319,7 @@ public void createFileChannel() throws IOException { var attrs = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); when(inUseFile.acquire()).thenReturn(false); - FileChannel ch = openCryptoFile.newFileChannel(options, false, attrs); + FileChannel ch = openCryptoFile.newFileChannel(options, attrs); Assertions.assertSame(cleartextFileChannel, ch); verify(chunkIO).registerChannel(ciphertextChannel.get(), true); verify(inUseFile).acquire(); @@ -342,7 +342,7 @@ public void errorDuringCreationOfSecondChannel() { Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { - openCryptoFile.newFileChannel(options, false); + openCryptoFile.newFileChannel(options); }); Assertions.assertSame(expectedException, exception); verify(closeListener, Mockito.never()).close(CURRENT_FILE_PATH.get(), openCryptoFile); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java index 09297619..7e8d5cb1 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFilesTest.java @@ -33,7 +33,7 @@ public void setup() throws IOException, ReflectiveOperationException { Mockito.when(subComponent.openCryptoFile()).thenReturn(file); Mockito.when(openCryptoFileComponentFactory.create(Mockito.any(), Mockito.any())).thenReturn(subComponent); - Mockito.when(file.newFileChannel(Mockito.any(), anyBoolean())).thenReturn(ciphertextFileChannel); + Mockito.when(file.newFileChannel(Mockito.any())).thenReturn(ciphertextFileChannel); inTest = new OpenCryptoFiles(openCryptoFileComponentFactory); } From 3bce755ebd60cbd6d0fe51f8875ac9917a7b17e3 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 1 Sep 2025 18:07:19 +0200 Subject: [PATCH 37/95] extend unit tests for OpenCryptoFile --- .../cryptofs/fh/OpenCryptoFile.java | 13 +- .../cryptofs/fh/OpenCryptoFileTest.java | 127 ++++++++++++------ 2 files changed, 98 insertions(+), 42 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index 9bdd93ac..a89aa2ec 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -44,6 +44,15 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol @CurrentOpenFilePath AtomicReference currentFilePath, @OpenFileSize AtomicLong fileSize, // @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component, // InUseManager inUseManager) { + this(listener, cryptor, headerHolder, chunkIO, currentFilePath, fileSize, lastModified, component, inUseManager, UseToken.CLOSED_TOKEN); + } + + + //for testing + OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHolder headerHolder, ChunkIO chunkIO, // + @CurrentOpenFilePath AtomicReference currentFilePath, @OpenFileSize AtomicLong fileSize, // + @OpenFileModifiedDate AtomicReference lastModified, OpenCryptoFileComponent component, // + InUseManager inUseManager, UseToken token) { this.listener = listener; this.cryptor = cryptor; this.headerHolder = headerHolder; @@ -53,7 +62,7 @@ public OpenCryptoFile(FileCloseListener listener, Cryptor cryptor, FileHeaderHol this.component = component; this.lastModified = lastModified; this.inUseManager = inUseManager; - this.useToken = UseToken.CLOSED_TOKEN; + this.useToken = token; } /** @@ -74,7 +83,7 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { //TODO: what about read-only file channels? Then we need to update logic, that first writable channel needs to create this file - if (useToken.isClosed() ) { //the token was closed prematurely, so we try to get a new one + if (useToken.isClosed()) { //the token was closed prematurely, so we try to get a new one useToken = inUseManager.use(path); } ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index f50625c4..10ba3842 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -6,6 +6,8 @@ import org.cryptomator.cryptofs.ReadonlyFlag; import org.cryptomator.cryptofs.ch.ChannelComponent; import org.cryptomator.cryptofs.ch.CleartextFileChannel; +import org.cryptomator.cryptofs.inuse.InUseManager; +import org.cryptomator.cryptofs.inuse.UseToken; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileHeader; import org.cryptomator.cryptolib.api.FileHeaderCryptor; @@ -13,6 +15,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Nested; @@ -26,6 +29,7 @@ import java.io.UncheckedIOException; import java.nio.channels.FileChannel; import java.nio.file.FileSystem; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.PosixFilePermissions; @@ -59,7 +63,8 @@ public class OpenCryptoFileTest { private OpenCryptoFileComponent openCryptoFileComponent = mock(OpenCryptoFileComponent.class); private ChannelComponent.Factory channelComponentFactory = mock(ChannelComponent.Factory.class); private ChannelComponent channelComponent = mock(ChannelComponent.class); - private InUseFile inUseFile = mock(InUseFile.class); + private InUseManager inUseManager = mock(InUseManager.class); + private UseToken useToken = spy(UseToken.INIT_TOKEN); //sealed class, hence we spy on existing token @BeforeAll public static void setup() { @@ -72,24 +77,39 @@ public static void tearDown() throws IOException { FS.close(); } + @BeforeEach + public void beforeEach() { + when(useToken.isClosed()).thenReturn(false); + } + + OpenCryptoFile getTestInstance(String filename) { + var p = FS.getPath(filename); + if (Files.exists(p)) { + 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); + } + @Test @DisplayName("on close(), trigger closeListener and delete lockFile") public void testClose() { - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); + var openCryptoFile = getTestInstance("testClose"); + var expectedCiphertextPath = CURRENT_FILE_PATH.get(); + openCryptoFile.close(); - verify(closeListener).close(CURRENT_FILE_PATH.get(), openCryptoFile); - verify(inUseFile).close(); + verify(closeListener).close(expectedCiphertextPath, openCryptoFile); + verify(useToken).close(); } // tests https://github.com/cryptomator/cryptofs/issues/51 @Test @DisplayName("if the first file channel fails to open, call OpenCryptoFile::close") - public void testFailedFirstFileChannelImmediatelyCallsClose() throws FileAlreadyInUseException { + public void testFailedFirstFileChannelImmediatelyCallsClose() { UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - when(inUseFile.acquire()).thenReturn(true); - OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); + var openCryptoFile = spy(getTestInstance("testClose")); UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { openCryptoFile.newFileChannel(options); @@ -99,28 +119,57 @@ public void testFailedFirstFileChannelImmediatelyCallsClose() throws FileAlready } @Test - @DisplayName("skip inUseFile, if flag is set") - public void testSkipInUseCheck() throws FileAlreadyInUseException { - UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); - EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); - Mockito.when(options.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - when(inUseFile.acquire()).thenThrow(FileAlreadyInUseException.class); - OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); + @DisplayName("if useToken is not closed, don't reassign it") + public void testNewFileChannelOpenUseToken() throws IOException { + var openCryptoFile = spy(getTestInstance("testNewFileChannelOpenUseToken")); + var expectedCiphertextPath = CURRENT_FILE_PATH.get(); - UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { - openCryptoFile.newFileChannel(options); - }); - Assertions.assertSame(expectedException, exception); - verify(openCryptoFile).close(); - verify(inUseFile, never()).acquire(); + EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); + var cleartextChannel = mock(CleartextFileChannel.class); + Mockito.when(headerHolder.get()).thenReturn(Mockito.mock(FileHeader.class)); + Mockito.when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(42); + Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); + Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); + Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); + when(useToken.isClosed()).thenReturn(false); + + openCryptoFile.newFileChannel(options); + + verify(inUseManager, never()).use(expectedCiphertextPath); + } + + @Test + @DisplayName("if useToken is closed, get a new one") + public void testNewFileChannelClosedToken() throws IOException { + var openCryptoFile = spy(getTestInstance("testNewFileChannelClosedUseToken")); + var expectedCiphertextPath = CURRENT_FILE_PATH.get(); + + EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); + var cleartextChannel = mock(CleartextFileChannel.class); + Mockito.when(headerHolder.get()).thenReturn(Mockito.mock(FileHeader.class)); + Mockito.when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(42); + Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); + Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); + Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); + when(useToken.isClosed()).thenReturn(true); + when(inUseManager.use(expectedCiphertextPath)).thenReturn(useToken); + + openCryptoFile.newFileChannel(options); + + verify(inUseManager).use(expectedCiphertextPath); } @Test @DisplayName("if the file is in use, throw exception") public void testInUseFileThrowsException() throws FileAlreadyInUseException { + var openCryptoFile = spy(getTestInstance("testInUseFileThrowsException")); + var expectedCiphertextPath = CURRENT_FILE_PATH.get(); + EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); - when(inUseFile.acquire()).thenThrow(FileAlreadyInUseException.class); - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); + when(useToken.isClosed()).thenReturn(true); + when(inUseManager.use(expectedCiphertextPath)).thenThrow(FileAlreadyInUseException.class); Assertions.assertThrows(FileAlreadyInUseException.class, () -> { openCryptoFile.newFileChannel(options); @@ -130,7 +179,8 @@ public void testInUseFileThrowsException() throws FileAlreadyInUseException { @Test @DisplayName("if the second file channel fails to open, do nothing") public void testFailedSecondFileChannelDoesNothing() throws IOException { - CURRENT_FILE_PATH.set(FS.getPath("secondDoesNotFail")); + var openCryptoFile = spy(getTestInstance("testFailedSecondFileChannelDoesNothing")); + UncheckedIOException expectedException = new UncheckedIOException(new IOException("fail!")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); var cleartextChannel = mock(CleartextFileChannel.class); @@ -140,11 +190,9 @@ public void testFailedSecondFileChannelDoesNothing() throws IOException { Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); - when(inUseFile.acquire()).thenReturn(false); EffectiveOpenOptions failingOptions = Mockito.mock(EffectiveOpenOptions.class); Mockito.when(failingOptions.createOpenOptionsForEncryptedFile()).thenThrow(expectedException); - OpenCryptoFile openCryptoFile = spy(new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile)); try (var channel = openCryptoFile.newFileChannel(options)) { UncheckedIOException exception = Assertions.assertThrows(UncheckedIOException.class, () -> { @@ -153,13 +201,13 @@ public void testFailedSecondFileChannelDoesNothing() throws IOException { Assertions.assertSame(expectedException, exception); verify(openCryptoFile, never()).close(); } - verify(inUseFile, times(1)).acquire(); } @Test @DisplayName("Opening a file channel with TRUNCATE_EXISTING calls truncate(0) on the cleartextChannel") public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOException { - CURRENT_FILE_PATH.set(FS.getPath("truncate")); + var openCryptoFile = spy(getTestInstance("testCleartextChannelTruncateCalled")); + EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING), readonlyFlag); var cleartextChannel = mock(CleartextFileChannel.class); Mockito.when(headerHolder.get()).thenReturn(Mockito.mock(FileHeader.class)); @@ -168,8 +216,6 @@ public void testCleartextChannelTruncateCalledOnTruncateExisting() throws IOExce Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); - OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseFile); - when(inUseFile.acquire()).thenReturn(true); openCryptoFile.newFileChannel(options); verify(cleartextChannel).truncate(0L); @@ -181,23 +227,23 @@ 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, inUseFile); - - doNothing().when(inUseFile).move(currentPath); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, currentPathWrapper, fileSize, lastModified, openCryptoFileComponent, inUseManager, useToken); + doNothing().when(useToken).moveTo(newPath); openCryptoFile.updateCurrentFilePath(newPath); - verify(inUseFile).move(currentPath); + verify(useToken).moveTo(newPath); } @Test - @DisplayName("Updating the current file path with null skips in-use-file") + @DisplayName("Updating the current file path with null closes in-use-file") 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, inUseFile); + OpenCryptoFile openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, currentPathWrapper, fileSize, lastModified, openCryptoFileComponent, inUseManager, useToken); + doNothing().when(useToken).close(); openCryptoFile.updateCurrentFilePath(null); - verify(inUseFile, never()).move(any()); + verify(useToken).close(); } @@ -207,7 +253,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, inUseFile); + OpenCryptoFile inTest = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, fileSize, lastModified, openCryptoFileComponent, inUseManager); @Test @DisplayName("Skip file header init, if the file header already exists in memory") @@ -291,7 +337,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, inUseFile); + openCryptoFile = new OpenCryptoFile(closeListener, cryptor, headerHolder, chunkIO, CURRENT_FILE_PATH, realFileSize, lastModified, openCryptoFileComponent, inUseManager); cleartextFileChannel = mock(CleartextFileChannel.class); listener = new AtomicReference<>(); ciphertextChannel = new AtomicReference<>(); @@ -316,13 +362,14 @@ public void testGetSizeBeforeCreatingFileChannel() { @Order(10) @DisplayName("create first FileChannel") public void createFileChannel() throws IOException { + var expectedCiphertextPath = CURRENT_FILE_PATH.get(); var attrs = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")); EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), readonlyFlag); - when(inUseFile.acquire()).thenReturn(false); + when(inUseManager.use(expectedCiphertextPath)).thenReturn(useToken); FileChannel ch = openCryptoFile.newFileChannel(options, attrs); Assertions.assertSame(cleartextFileChannel, ch); verify(chunkIO).registerChannel(ciphertextChannel.get(), true); - verify(inUseFile).acquire(); + verify(inUseManager).use(expectedCiphertextPath); } @Test From 05a326ffd2b3d012bf03776fadae63f8cc59e7e3 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 2 Sep 2025 09:31:41 +0200 Subject: [PATCH 38/95] enable clean compilation --- .../org/cryptomator/cryptofs/CryptoFileSystemImplTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 06fcd4c5..3ea3ce66 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -16,6 +16,8 @@ import org.cryptomator.cryptofs.fh.OpenCryptoFile; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptofs.fh.OpenCryptoFiles.TwoPhaseMove; +import org.cryptomator.cryptofs.inuse.InUseManager; +import org.cryptomator.cryptofs.inuse.StubInUseManager; import org.cryptomator.cryptofs.mocks.FileChannelMock; import org.cryptomator.cryptolib.api.Cryptor; import org.hamcrest.CoreMatchers; @@ -108,6 +110,7 @@ public class CryptoFileSystemImplTest { private final CryptoFileSystemProperties fileSystemProperties = mock(CryptoFileSystemProperties.class); private final FileNameDecryptor filenameDecryptor = mock(FileNameDecryptor.class); private final Consumer eventConsumer = mock(Consumer.class); + private final InUseManager inUseManager = mock(InUseManager.class); private final CryptoPath root = mock(CryptoPath.class); private final CryptoPath empty = mock(CryptoPath.class); @@ -130,7 +133,7 @@ public void setup() { pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup, // fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, // openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, // - fileSystemProperties, filenameDecryptor, eventConsumer); + fileSystemProperties, inUseManager, filenameDecryptor, eventConsumer); } @Test From 732bf7fecc16da96df8f672d7d63a0d8ff0ee9bd Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 2 Sep 2025 10:17:04 +0200 Subject: [PATCH 39/95] add owner to CryptoFileSystemProperties --- .../cryptofs/CryptoFileSystemModule.java | 5 ++-- .../cryptofs/CryptoFileSystemProperties.java | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index 6d9a7da7..e45ea7d6 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -22,6 +22,7 @@ import java.nio.file.FileStore; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; @@ -57,8 +58,8 @@ public Consumer provideFilesystemEventConsumer(CryptoFileSystem @Provides @CryptoFileSystemScoped public InUseManager provideInUseManager(CryptoFileSystemProperties fsProps) { - var owner = (String) fsProps.get("owner"); //TODO - if(owner != null && !fsProps.readonly()) { + var owner = Objects.requireNonNullElse(fsProps.owner(),""); + if(!owner.isBlank() && !fsProps.readonly()) { return new RealInUseManager(owner); } else { return new StubInUseManager(); diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java index 30bffeea..2fe1ad33 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java @@ -115,6 +115,14 @@ public enum FileSystemFlags { static final CryptorProvider.Scheme DEFAULT_CIPHER_COMBO = CryptorProvider.Scheme.SIV_GCM; + /** + * Key identifying the filesystem owner. + * + * @since 2.10.0 + */ + public static final String PROPERTY_OWNER = "owner"; + static final String DEFAULT_OWNER = ""; + private final Set> entries; private CryptoFileSystemProperties(Builder builder) { @@ -126,7 +134,8 @@ private CryptoFileSystemProperties(Builder builder) { Map.entry(PROPERTY_EVENT_CONSUMER, builder.eventConsumer), // Map.entry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, builder.maxCleartextNameLength), // Map.entry(PROPERTY_SHORTENING_THRESHOLD, builder.shorteningThreshold), // - Map.entry(PROPERTY_CIPHER_COMBO, builder.cipherCombo) // + Map.entry(PROPERTY_CIPHER_COMBO, builder.cipherCombo), // + Map.entry(PROPERTY_OWNER, builder.owner) // ); } @@ -169,6 +178,10 @@ Consumer filesystemEventConsumer() { return (Consumer) get(PROPERTY_EVENT_CONSUMER); } + String owner() { + return (String) get(PROPERTY_OWNER); + } + @Override public Set> entrySet() { return entries; @@ -225,6 +238,7 @@ public static class Builder { private int maxCleartextNameLength = DEFAULT_MAX_CLEARTEXT_NAME_LENGTH; private int shorteningThreshold = DEFAULT_SHORTENING_THRESHOLD; private Consumer eventConsumer = DEFAULT_EVENT_CONSUMER; + private String owner = DEFAULT_OWNER; private Builder() { } @@ -238,6 +252,7 @@ private Builder(Map properties) { checkedSet(Integer.class, PROPERTY_SHORTENING_THRESHOLD, properties, this::withShorteningThreshold); checkedSet(CryptorProvider.Scheme.class, PROPERTY_CIPHER_COMBO, properties, this::withCipherCombo); checkedSet(Consumer.class, PROPERTY_EVENT_CONSUMER, properties, this::withFilesystemEventConsumer); + checkedSet(String.class, PROPERTY_OWNER, properties, this::withOwner); } private void checkedSet(Class type, String key, Map properties, Consumer setter) { @@ -367,6 +382,18 @@ public Builder withFilesystemEventConsumer(Consumer eventConsum return this; } + /** + * Sets the owner of the filesystem + * + * @param owner the owner string used when marking files in-use + * @return this + * @since 2.10.0 + */ + public Builder withOwner(String owner) { + this.owner = owner; + return this; + } + /** * Validates the values and creates new {@link CryptoFileSystemProperties}. * From 901f058bf1f2b7116840aa8362e4a7a83350032d Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 2 Sep 2025 16:29:18 +0200 Subject: [PATCH 40/95] adjust CryptoFileSystemImpl tests to new API --- .../cryptofs/CryptoFileSystemImpl.java | 3 +- .../cryptofs/CryptoFileSystemImplTest.java | 101 +++++++++++------- 2 files changed, 63 insertions(+), 41 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 893f4492..b60cd90a 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -725,7 +725,8 @@ public String toString() { return format("%sCryptoFileSystem(%s)", open ? "" : "closed ", pathToVault); } - private void checkUsage(CryptoPath cleartextPath, CiphertextFilePath ciphertextPath) throws FileAlreadyInUseException { + //visible for testing + void checkUsage(CryptoPath cleartextPath, CiphertextFilePath ciphertextPath) throws FileAlreadyInUseException { if (inUseManager.isInUseByOthers(ciphertextPath.getFilePath())) { eventConsumer.accept(new FileIsInUseEvent(cleartextPath, ciphertextPath.getRawPath(), new Properties())); //TODO: properties?? throw new FileAlreadyInUseException(ciphertextPath.getRawPath()); diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 3ea3ce66..647ff636 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -12,12 +12,10 @@ import org.cryptomator.cryptofs.event.FileIsInUseEvent; import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; -import org.cryptomator.cryptofs.fh.InUseFile; import org.cryptomator.cryptofs.fh.OpenCryptoFile; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptofs.fh.OpenCryptoFiles.TwoPhaseMove; import org.cryptomator.cryptofs.inuse.InUseManager; -import org.cryptomator.cryptofs.inuse.StubInUseManager; import org.cryptomator.cryptofs.mocks.FileChannelMock; import org.cryptomator.cryptolib.api.Cryptor; import org.hamcrest.CoreMatchers; @@ -80,8 +78,6 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import static org.mockito.internal.verification.VerificationModeFactory.atLeast; @@ -443,6 +439,39 @@ public void testNewWatchServiceThrowsUnsupportedOperationException() throws IOEx }); } + @Test + @DisplayName("checkUsage throws exception when file is in-use") + public void testCheckUsageThrowsException() throws FileAlreadyInUseException { + CryptoPath cleartextPath = mock(CryptoPath.class, "cleartext"); + CryptoPath ciphertextFilePath = mock(CryptoPath.class, "d/00/00/path.c9r"); + CryptoPath ciphertextRawPath = mock(CryptoPath.class, "d/00/00/path_raw.c9r"); + CiphertextFilePath ciphertextPath = mock(CiphertextFilePath.class); + when(ciphertextPath.getFilePath()).thenReturn(ciphertextFilePath); + when(ciphertextPath.getRawPath()).thenReturn(ciphertextRawPath); + + when(inUseManager.isInUseByOthers(ciphertextFilePath)).thenReturn(true); + + Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.checkUsage(cleartextPath, ciphertextPath)); + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent && ((FileIsInUseEvent) ev).cleartext().equals(cleartextPath); + verify(inUseManager).isInUseByOthers(ciphertextFilePath); + verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); + } + + @Test + @DisplayName("checkUsage throws exception when file is in-use") + public void testCheckUsageForNotInUseFiles() throws FileAlreadyInUseException { + CryptoPath cleartextPath = mock(CryptoPath.class, "cleartext"); + CryptoPath ciphertextFilePath = mock(CryptoPath.class, "d/00/00/path.c9r"); + CiphertextFilePath ciphertextPath = mock(CiphertextFilePath.class); + when(ciphertextPath.getFilePath()).thenReturn(ciphertextFilePath); + + when(inUseManager.isInUseByOthers(ciphertextFilePath)).thenReturn(false); + + Assertions.assertDoesNotThrow(() -> inTest.checkUsage(cleartextPath, ciphertextPath)); + verify(inUseManager).isInUseByOthers(ciphertextFilePath); + verify(eventConsumer, never()).accept(any()); + } + @Nested public class NewFileChannel { @@ -563,8 +592,7 @@ public void testNewFileChannelInUseFailure() throws IOException { when(openCryptoFile.newFileChannel(any())).thenThrow(FileAlreadyInUseException.class); Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.newFileChannel(cleartextPath, EnumSet.of(StandardOpenOption.WRITE))); - var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent - && ((FileIsInUseEvent) ev).cleartext().equals(cleartextPath); + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent && ((FileIsInUseEvent) ev).cleartext().equals(cleartextPath); verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); } @@ -608,24 +636,19 @@ public void testDeleteRootFails() { @Test public void testDeleteRegularExistingFile() throws IOException { - var inUsePath = mock(Path.class, "in use file"); - when(inUsePath.getFileSystem()).thenReturn(physicalFs); when(cryptoPathMapper.getCiphertextFileType(cleartextPath)).thenReturn(CiphertextFileType.FILE); when(physicalFsProv.deleteIfExists(ciphertextRawPath)).thenReturn(true); - when(physicalFsProv.deleteIfExists(inUsePath)).thenReturn(true); doNothing().when(openCryptoFiles).delete(Mockito.any()); when(ciphertextPath.isShortened()).thenReturn(false); + var inTestSpy = spy(inTest); + doNothing().when(inTestSpy).checkUsage(cleartextPath, ciphertextPath); - - try (var inUseClassMock = mockStatic(InUseFile.class)) { - inUseClassMock.when(() -> InUseFile.computeInUseFilePath(ciphertextFilePath)).thenReturn(inUsePath); - inTest.delete(cleartextPath); - } + inTestSpy.delete(cleartextPath); verify(readonlyFlag).assertWritable(); verify(openCryptoFiles).delete(ciphertextFilePath); verify(physicalFsProv).deleteIfExists(ciphertextRawPath); - verify(physicalFsProv).deleteIfExists(inUsePath); + verify(inTestSpy).checkUsage(cleartextPath, ciphertextPath); } @Test @@ -636,10 +659,7 @@ public void testDeleteShortenedExistingFile() throws IOException { doNothing().when(openCryptoFiles).delete(Mockito.any()); when(ciphertextPath.isShortened()).thenReturn(true); - try (var inUseClassMock = mockStatic(InUseFile.class)) { - inUseClassMock.when(() -> InUseFile.computeInUseFilePath(ciphertextFilePath)).thenReturn(inUsePath); - inTest.delete(cleartextPath); - } + inTest.delete(cleartextPath); verify(readonlyFlag).assertWritable(); verify(openCryptoFiles).delete(ciphertextFilePath); @@ -654,18 +674,14 @@ public void testDeleteInUseFileThrows() throws IOException { when(physicalFsProv.deleteIfExists(ciphertextRawPath)).thenReturn(true); doNothing().when(openCryptoFiles).delete(Mockito.any()); when(ciphertextPath.isShortened()).thenReturn(false); + var inTestSpy = spy(inTest); + doThrow(FileAlreadyInUseException.class).when(inTestSpy).checkUsage(cleartextPath, ciphertextPath); - try (var inUseClassMock = mockStatic(InUseFile.class)) { - inUseClassMock.when(() -> InUseFile.isInUse(eq(ciphertextFilePath), anyString())).thenReturn(true); - Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.delete(cleartextPath)); - } + Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTestSpy.delete(cleartextPath)); verify(openCryptoFiles, never()).delete(ciphertextFilePath); verify(physicalFsProv, never()).deleteIfExists(ciphertextRawPath); verify(physicalFsProv, never()).deleteIfExists(inUsePath); - var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent // - && ((FileIsInUseEvent) ev).cleartext().equals(cleartextPath); - verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); } @@ -840,27 +856,36 @@ public void moveFile() throws IOException { CopyOption option1 = mock(CopyOption.class); CopyOption option2 = mock(CopyOption.class); - try (var inUseClass = mockStatic(InUseFile.class)) { - inUseClass.when(() -> InUseFile.isInUse(eq(ciphertextDestinationFile), any())).thenReturn(false); - inTest.move(cleartextSource, cleartextDestination, option1, option2); - } + var inTestSpy = spy(inTest); + doNothing().when(inTestSpy).checkUsage(cleartextSource, ciphertextSource); + doNothing().when(inTestSpy).checkUsage(cleartextDestination, ciphertextDestination); + + inTestSpy.move(cleartextSource, cleartextDestination, option1, option2); verify(readonlyFlag).assertWritable(); verify(physicalFsProv).move(ciphertextSourceFile, ciphertextDestinationFile, option1, option2); verify(openFileMove).commit(); + verify(inTestSpy).checkUsage(cleartextSource, ciphertextSource); + verify(inTestSpy).checkUsage(cleartextDestination, ciphertextDestination); } @Test public void moveFileWithSourceInUse() throws IOException { - moveFileWithXInUse(ciphertextSourceFile, cleartextSource); + var inTestSpy = spy(inTest); + doThrow(FileAlreadyInUseException.class).when(inTestSpy).checkUsage(cleartextSource, ciphertextSource); + doNothing().when(inTestSpy).checkUsage(cleartextDestination, ciphertextDestination); + moveFileWithXInUse(inTestSpy); } @Test public void moveFileWithTargetInUse() throws IOException { - moveFileWithXInUse(ciphertextDestinationFile, cleartextDestination); + var inTestSpy = spy(inTest); + doNothing().when(inTestSpy).checkUsage(cleartextSource, ciphertextSource); + doThrow(FileAlreadyInUseException.class).when(inTestSpy).checkUsage(cleartextDestination, ciphertextDestination); + moveFileWithXInUse(inTestSpy); } - private void moveFileWithXInUse(Path usedCipherPath, CryptoPath usedClearPath) throws IOException { + private void moveFileWithXInUse(CryptoFileSystemImpl inTestSpy) throws IOException { when(cryptoPathMapper.getCiphertextFileType(cleartextSource)).thenReturn(CiphertextFileType.FILE); when(cryptoPathMapper.getCiphertextFileType(cleartextDestination)).thenThrow(NoSuchFileException.class); TwoPhaseMove openFileMove = Mockito.mock(TwoPhaseMove.class); @@ -869,15 +894,11 @@ private void moveFileWithXInUse(Path usedCipherPath, CryptoPath usedClearPath) t CopyOption option1 = mock(CopyOption.class); CopyOption option2 = mock(CopyOption.class); - try (var inUseClass = mockStatic(InUseFile.class)) { - inUseClass.when(() -> InUseFile.isInUse(eq(usedCipherPath), any())).thenReturn(true); - Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.move(cleartextSource, cleartextDestination, option1, option2)); - } + + Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTestSpy.move(cleartextSource, cleartextDestination, option1, option2)); + verify(readonlyFlag).assertWritable(); verify(physicalFsProv, never()).move(ciphertextSourceFile, ciphertextDestinationFile, option1, option2); - var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent - && ((FileIsInUseEvent) ev).cleartext().equals(usedClearPath); - verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); } From 17319a33b2fce1a40bc62afa3b2dfd7b40d6caf4 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 2 Sep 2025 17:04:35 +0200 Subject: [PATCH 41/95] fix unit tests --- ...toFileChannelWriteReadIntegrationTest.java | 60 +++++++++++++++---- .../CryptoFileSystemPropertiesTest.java | 20 +++++-- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java index 5b6ec297..ff820c2a 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java @@ -9,6 +9,7 @@ package org.cryptomator.cryptofs; import com.google.common.jimfs.Jimfs; +import org.awaitility.Awaitility; import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.util.ByteBuffers; import org.cryptomator.cryptolib.api.Masterkey; @@ -144,6 +145,55 @@ public void testLastModifiedIsPreservedOverSeveralOperations() throws IOExceptio } + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + public class InUseFeatureActivated { + + private FileSystem inMemoryFs; + private FileSystem fileSystem; + + private Path file; + private Path vaultPath; + + @BeforeAll + public void beforeAll() throws IOException, MasterkeyLoadingFailedException { + inMemoryFs = Jimfs.newFileSystem(); + vaultPath = inMemoryFs.getPath("vault"); + Files.createDirectories(vaultPath); + MasterkeyLoader keyLoader = Mockito.mock(MasterkeyLoader.class); + Mockito.when(keyLoader.loadKey(Mockito.any())).thenAnswer(ignored -> new Masterkey(new byte[64])); + var properties = cryptoFileSystemProperties().withKeyLoader(keyLoader).withOwner("cryptobot").build(); + CryptoFileSystemProvider.initialize(vaultPath, properties, URI.create("test:key")); + fileSystem = CryptoFileSystemProvider.newFileSystem(vaultPath, properties); + file = fileSystem.getPath("/test.txt"); + } + + @AfterAll + public void afterAll() throws IOException { + fileSystem.close(); + inMemoryFs.close(); + } + + @Test + @DisplayName("Opening a file channel creates an in-use file and removes it on close") + public void testOpeningFCCreatesInUseFile() throws IOException { + try (var writer = FileChannel.open(file, CREATE, WRITE)) { + Awaitility.await().atLeast(Constants.IN_USE_DELAY_MILLIS - 100, TimeUnit.MILLISECONDS) // + .atMost(Constants.IN_USE_DELAY_MILLIS + 3000, TimeUnit.MILLISECONDS) // + .until(() -> numberOfInUseFiles() == 1); + } + var numberAfterClose = numberOfInUseFiles(); + Assertions.assertEquals(0, numberAfterClose); + } + + private long numberOfInUseFiles() throws IOException { + try (var encryptedFiles = Files.walk(vaultPath.resolve("d"))) { + var inUseFiles = encryptedFiles.filter(p -> p.getFileName().toString().endsWith(Constants.INUSE_FILE_SUFFIX)); + return inUseFiles.count(); + } + } + } + @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class PlatformIndependent { @@ -178,16 +228,6 @@ public void afterEach() throws IOException { Files.deleteIfExists(file); } - @Test - @DisplayName("Opening a file channel creates an in-use file and removes it on close") - public void testOpeningFCCreatesInUseFile() throws IOException { - try (var writer = FileChannel.open(file, CREATE, WRITE)) { - var inUseFiles = Files.walk(vaultPath.resolve("d")).filter( p -> p.getFileName().toString().endsWith(Constants.INUSE_FILE_SUFFIX)).toList(); - Assertions.assertEquals(1, inUseFiles.size()); - } - var inUseFiles = Files.walk(vaultPath.resolve("d")).filter( p -> p.getFileName().toString().endsWith(Constants.INUSE_FILE_SUFFIX)).toList(); - Assertions.assertEquals(0, inUseFiles.size()); - } //https://github.com/cryptomator/cryptofs/issues/173 @Test diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java index 2b689031..dbaf79ae 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java @@ -52,7 +52,9 @@ public void testSetMasterkeyFilenameAndReadonlyFlag() { anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // - anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER), // + anEntry(PROPERTY_OWNER, DEFAULT_OWNER)) + ); } @Test @@ -79,7 +81,9 @@ public void testFromMap() { anEntry(PROPERTY_SHORTENING_THRESHOLD, 221), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // - anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER), // + anEntry(PROPERTY_OWNER, DEFAULT_OWNER)) // + ); } @Test @@ -102,7 +106,9 @@ public void testWrapMapWithTrueReadonly() { anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // - anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER), // + anEntry(PROPERTY_OWNER, DEFAULT_OWNER)) // + ); } @Test @@ -125,7 +131,9 @@ public void testWrapMapWithFalseReadonly() { anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)), // - anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER), // + anEntry(PROPERTY_OWNER, DEFAULT_OWNER)) // + ); } @Test @@ -194,7 +202,9 @@ public void testWrapMapWithoutReadonly() { anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)), // - anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER), // + anEntry(PROPERTY_OWNER, DEFAULT_OWNER)) // + ); } @Test From 775e0a0b26124e00b866557f5ccd19d421375cec Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 5 Sep 2025 13:45:31 +0200 Subject: [PATCH 42/95] Add cryptor to RealInUseManager Additionally, refactor encryption/decryption wrapping of channels to own class. --- .../cryptofs/CryptoFileSystemModule.java | 5 ++-- .../cryptofs/DirectoryIdBackup.java | 12 +++------ .../cryptofs/common/EncryptedChannels.java | 22 +++++++++++++++ .../cryptofs/inuse/RealInUseManager.java | 9 ++++--- .../cryptofs/DirectoryIdBackupTest.java | 27 ++++++++++++------- .../cryptofs/inuse/RealInUseManagerTest.java | 23 +++++++++------- 6 files changed, 64 insertions(+), 34 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/common/EncryptedChannels.java diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index e45ea7d6..7a241cc4 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -15,6 +15,7 @@ import org.cryptomator.cryptofs.inuse.InUseManager; import org.cryptomator.cryptofs.fh.OpenCryptoFileComponent; import org.cryptomator.cryptofs.inuse.RealInUseManager; +import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,10 +58,10 @@ public Consumer provideFilesystemEventConsumer(CryptoFileSystem @Provides @CryptoFileSystemScoped - public InUseManager provideInUseManager(CryptoFileSystemProperties fsProps) { + public InUseManager provideInUseManager(CryptoFileSystemProperties fsProps, Cryptor cryptor) { var owner = Objects.requireNonNullElse(fsProps.owner(),""); if(!owner.isBlank() && !fsProps.readonly()) { - return new RealInUseManager(owner); + return new RealInUseManager(owner, cryptor); } else { return new StubInUseManager(); } diff --git a/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java b/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java index da2f7dca..106acdb7 100644 --- a/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java +++ b/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java @@ -2,6 +2,7 @@ import jakarta.inject.Inject; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; @@ -38,7 +39,7 @@ public DirectoryIdBackup(Cryptor cryptor) { */ public void write(CiphertextDirectory ciphertextDirectory) throws IOException { try (var channel = Files.newByteChannel(getBackupFilePath(ciphertextDirectory.path()), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); // - var encryptingChannel = wrapEncryptionAround(channel, cryptor)) { + var encryptingChannel = EncryptedChannels.wrapEncryptionAround(channel, cryptor)) { encryptingChannel.write(ByteBuffer.wrap(ciphertextDirectory.dirId().getBytes(StandardCharsets.US_ASCII))); } } @@ -72,7 +73,7 @@ public byte[] read(Path ciphertextContentDir) throws IOException, CryptoExceptio var dirIdBuffer = ByteBuffer.allocate(Constants.MAX_DIR_ID_LENGTH + 1); //a dir id contains at most 36 ascii chars, we add for security checks one more try (var channel = Files.newByteChannel(dirIdBackupFile, StandardOpenOption.READ); // - var decryptingChannel = wrapDecryptionAround(channel, cryptor)) { + var decryptingChannel = EncryptedChannels.wrapDecryptionAround(channel, cryptor)) { int read = decryptingChannel.read(dirIdBuffer); if (read < 0 || read > Constants.MAX_DIR_ID_LENGTH) { throw new IllegalStateException("Read directory id exceeds the maximum length of %d characters".formatted(Constants.MAX_DIR_ID_LENGTH)); @@ -103,11 +104,4 @@ private static Path getBackupFilePath(Path ciphertextContentDir) { return ciphertextContentDir.resolve(Constants.DIR_ID_BACKUP_FILE_NAME); } - DecryptingReadableByteChannel wrapDecryptionAround(ByteChannel channel, Cryptor cryptor) { - return new DecryptingReadableByteChannel(channel, cryptor, true); - } - - EncryptingWritableByteChannel wrapEncryptionAround(ByteChannel channel, Cryptor cryptor) { - return new EncryptingWritableByteChannel(channel, cryptor); - } } diff --git a/src/main/java/org/cryptomator/cryptofs/common/EncryptedChannels.java b/src/main/java/org/cryptomator/cryptofs/common/EncryptedChannels.java new file mode 100644 index 00000000..5d52e157 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/common/EncryptedChannels.java @@ -0,0 +1,22 @@ +package org.cryptomator.cryptofs.common; + +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; +import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; + +import java.nio.channels.ByteChannel; + +public class EncryptedChannels { + + private EncryptedChannels() {} + + + public static DecryptingReadableByteChannel wrapDecryptionAround(ByteChannel channel, Cryptor cryptor) { + return new DecryptingReadableByteChannel(channel, cryptor, true); + } + + public static EncryptingWritableByteChannel wrapEncryptionAround(ByteChannel channel, Cryptor cryptor) { + return new EncryptingWritableByteChannel(channel, cryptor); + } + +} diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 82548dd7..48d93630 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -4,6 +4,7 @@ import org.cryptomator.cryptofs.common.FileTooBigException; import org.cryptomator.cryptofs.common.FileUtil; import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptolib.api.Cryptor; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,9 +31,11 @@ public class RealInUseManager implements InUseManager { private final ConcurrentMap useTokens; private final String owner; + private Cryptor cryptor; - public RealInUseManager(@NonNull String owner) { + public RealInUseManager(@NonNull String owner, Cryptor cryptor) { this.owner = owner; + this.cryptor = cryptor; this.useTokens = new ConcurrentHashMap<>(); } @@ -71,8 +74,6 @@ boolean isInUseInternal(Path inUseFilePath) throws IOException, IllegalArgumentE Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { //TODO: decryption var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); - //TODO: convert to JSON an extract info - // for now we use properties var props = new Properties(); try (var stream = new ByteArrayInputStream(bytes)) { props.load(stream); @@ -143,7 +144,7 @@ static Path computeInUseFilePath(Path p) { //for testing - RealInUseManager(String owner, ConcurrentMap useTokens) { + RealInUseManager(String owner, Cryptor cryptor, ConcurrentMap useTokens) { this.owner = owner; this.useTokens = useTokens; } diff --git a/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java b/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java index 9b2f38b1..618adffb 100644 --- a/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java +++ b/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java @@ -2,17 +2,20 @@ import com.google.common.io.BaseEncoding; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptofs.util.TestCryptoException; import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; import org.mockito.Mockito; import java.io.IOException; @@ -22,7 +25,10 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.spy; public class DirectoryIdBackupTest { @@ -32,14 +38,21 @@ public class DirectoryIdBackupTest { private String dirId = "12345678"; private Cryptor cryptor; + private MockedStatic ecMock; private DirectoryIdBackup dirIdBackup; @BeforeEach - public void init() { + public void beforeEach() { cryptor = Mockito.mock(Cryptor.class); dirIdBackup = new DirectoryIdBackup(cryptor); + ecMock = mockStatic(EncryptedChannels.class); + } + + @AfterEach + public void afterEach() { + ecMock.close(); } @Nested @@ -52,12 +65,12 @@ public class Write { public void beforeEachWriteTest() { ciphertextDirectoryObject = new CiphertextDirectory(dirId, testDir); encChannel = Mockito.mock(EncryptingWritableByteChannel.class); + ecMock.when(() -> EncryptedChannels.wrapEncryptionAround(any(), eq(cryptor))).thenReturn(encChannel); } @Test public void testIdFileCreated() throws IOException { var dirIdBackupSpy = spy(dirIdBackup); - Mockito.doReturn(encChannel).when(dirIdBackupSpy).wrapEncryptionAround(Mockito.any(), Mockito.eq(cryptor)); Mockito.when(encChannel.write(Mockito.any())).thenReturn(0); dirIdBackupSpy.write(ciphertextDirectoryObject); @@ -68,7 +81,6 @@ public void testIdFileCreated() throws IOException { @Test public void testContentIsWritten() throws IOException { var dirIdBackupSpy = spy(dirIdBackup); - Mockito.doReturn(encChannel).when(dirIdBackupSpy).wrapEncryptionAround(Mockito.any(), Mockito.eq(cryptor)); Mockito.when(encChannel.write(Mockito.any())).thenReturn(0); var expectedWrittenContent = ByteBuffer.wrap(dirId.getBytes(StandardCharsets.US_ASCII)); @@ -87,14 +99,15 @@ public class Read { @BeforeEach public void beforeEachRead() throws IOException { - var dirNames = BaseEncoding.base32().encode(new byte [20]); //a directory id hash is due to SHA1 always 20 bytes long - var twoCharDir = testDir.resolve(dirNames.substring(0,2)); + var dirNames = BaseEncoding.base32().encode(new byte[20]); //a directory id hash is due to SHA1 always 20 bytes long + var twoCharDir = testDir.resolve(dirNames.substring(0, 2)); cipherContentDir = twoCharDir.resolve(dirNames.substring(2)); var backupFile = cipherContentDir.resolve(Constants.DIR_ID_BACKUP_FILE_NAME); Files.createDirectories(cipherContentDir); Files.writeString(backupFile, dirId, StandardCharsets.US_ASCII, StandardOpenOption.CREATE, StandardOpenOption.WRITE); decChannel = mock(DecryptingReadableByteChannel.class); + ecMock.when(() -> EncryptedChannels.wrapDecryptionAround(any(), eq(cryptor))).thenReturn(decChannel); } @Test @@ -108,7 +121,6 @@ public void wrongPath() throws IOException { @DisplayName("If the directory id is longer than 36 characters, throw IllegalStateException") public void contentLongerThan36Chars() throws IOException { var dirIdBackupSpy = spy(dirIdBackup); - Mockito.when(dirIdBackupSpy.wrapDecryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(decChannel); Mockito.when(decChannel.read(Mockito.any())).thenReturn(Constants.MAX_DIR_ID_LENGTH + 1); Assertions.assertThrows(IllegalStateException.class, () -> dirIdBackupSpy.read(cipherContentDir)); } @@ -118,7 +130,6 @@ public void contentLongerThan36Chars() throws IOException { public void invalidEncryptionThrowsCryptoException() throws IOException { var dirIdBackupSpy = spy(dirIdBackup); var expectedException = new TestCryptoException(); - Mockito.when(dirIdBackupSpy.wrapDecryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(decChannel); Mockito.when(decChannel.read(Mockito.any())).thenThrow(expectedException); var actual = Assertions.assertThrows(CryptoException.class, () -> dirIdBackupSpy.read(cipherContentDir)); Assertions.assertEquals(expectedException, actual); @@ -129,7 +140,6 @@ public void invalidEncryptionThrowsCryptoException() throws IOException { public void ioException() throws IOException { var dirIdBackupSpy = spy(dirIdBackup); var expectedException = new IOException("my oh my"); - Mockito.when(dirIdBackupSpy.wrapDecryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(decChannel); Mockito.when(decChannel.read(Mockito.any())).thenThrow(expectedException); var actual = Assertions.assertThrows(IOException.class, () -> dirIdBackupSpy.read(cipherContentDir)); Assertions.assertEquals(expectedException, actual); @@ -141,7 +151,6 @@ public void success() throws IOException { var dirIdBackupSpy = spy(dirIdBackup); var expectedArray = dirId.getBytes(StandardCharsets.US_ASCII); - Mockito.when(dirIdBackupSpy.wrapDecryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(decChannel); Mockito.doAnswer(invocationOnMock -> { var buf = (ByteBuffer) invocationOnMock.getArgument(0); buf.put(expectedArray); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 3173ebca..4b5ee051 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -1,6 +1,7 @@ package org.cryptomator.cryptofs.inuse; import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptolib.api.Cryptor; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -29,11 +30,13 @@ public class RealInUseManagerTest { private MockedStatic staticManagerMock; private Path ciphertextPath; private Path inUseFilePath; + private Cryptor cryptor; @BeforeEach public void beforeEach() { ciphertextPath = mock(Path.class, "ciphertext.c9r"); inUseFilePath = mock(Path.class, "inUseFile.c9u"); + cryptor = mock(Cryptor.class); staticManagerMock = mockStatic(RealInUseManager.class); staticManagerMock.when(() -> RealInUseManager.computeInUseFilePath(ciphertextPath)).thenReturn(inUseFilePath); } @@ -43,7 +46,7 @@ public void beforeEach() { public void testUseByOthersWithExistingToken() throws IOException { var preparedMap = new ConcurrentHashMap(); preparedMap.put(inUseFilePath, mock(RealUseToken.class)); - var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); var inUseSpy = spy(inUseManager); var result = inUseSpy.isInUseByOthers(ciphertextPath); @@ -55,7 +58,7 @@ public void testUseByOthersWithExistingToken() throws IOException { @Test @DisplayName("Call internal inUse check, when map does not contain path") public void testUseByOthers() throws IOException { - var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); doReturn(true).when(inUseSpy).isInUseInternal(inUseFilePath); @@ -68,7 +71,7 @@ public void testUseByOthers() throws IOException { @DisplayName("If internalUse check fails with declared exception, return false") @ValueSource(classes = {IllegalArgumentException.class, IOException.class}) public void testUseByOthersException(Class exceptionClass) throws IOException { - var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); doThrow(exceptionClass).when(inUseSpy).isInUseInternal(inUseFilePath); @@ -80,7 +83,7 @@ public void testUseByOthersException(Class exceptionClass) throws IOException { @Test @DisplayName("\"use\" method places puts path into map and returns token") public void testUsePlacesPathInMap() throws FileAlreadyInUseException { - var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -93,7 +96,7 @@ public void testUsePlacesPathInMap() throws FileAlreadyInUseException { @Test @DisplayName("\"use\" method rethrow FileAlreadyInUseException") public void testUseThrows() throws FileAlreadyInUseException { - var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); var inUseException = new FileAlreadyInUseException(inUseFilePath); @@ -106,7 +109,7 @@ public void testUseThrows() throws FileAlreadyInUseException { @Test @DisplayName("\"use\" method returns CLOSED_TOKEN on IOException") public void testUseClosedToken() throws FileAlreadyInUseException { - var inUseManager = new RealInUseManager("cryptobot3000"); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); var someIOException = new IOException("it's over 9000!"); @@ -120,7 +123,7 @@ public void testUseClosedToken() throws FileAlreadyInUseException { @DisplayName("Create internal with existing in-use-file") public void testCreateExistingValid() throws IOException { var preparedMap = new ConcurrentHashMap(); - var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -139,7 +142,7 @@ public void testCreateExistingValid() throws IOException { @DisplayName("Create internal with INVALID in-use-file") public void testCreateExistingInvalid() throws IOException { var preparedMap = new ConcurrentHashMap(); - var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -158,7 +161,7 @@ public void testCreateExistingInvalid() throws IOException { @DisplayName("Create internal with NOT existing in-use-file") public void testCreateNotExisting() throws IOException { var preparedMap = new ConcurrentHashMap(); - var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -177,7 +180,7 @@ public void testCreateNotExisting() throws IOException { @DisplayName("Create internal throws UncheckedIO(FileAlreadyInUse) exception") public void testCreateFailedRead() throws IOException { var preparedMap = new ConcurrentHashMap(); - var inUseManager = new RealInUseManager("cryptobot3000", preparedMap); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); var inUseSpy = spy(inUseManager); doReturn(true).when(inUseSpy).isInUseInternal(inUseFilePath); From b0b694758636492ea63aaea40a4193f351026757 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 5 Sep 2025 17:44:18 +0200 Subject: [PATCH 43/95] Add encryption to inUse file --- .../cryptofs/CiphertextFilePath.java | 1 - .../cryptofs/common/FileTooBigException.java | 12 - .../cryptomator/cryptofs/common/FileUtil.java | 19 -- .../cryptomator/cryptofs/fh/InUseFile.java | 219 ------------------ .../cryptofs/inuse/RealInUseManager.java | 25 +- .../cryptofs/inuse/RealUseToken.java | 44 ++-- .../cryptofs/inuse/RealInUseManagerTest.java | 12 +- .../cryptofs/inuse/RealUseTokenTest.java | 33 ++- 8 files changed, 75 insertions(+), 290 deletions(-) delete mode 100644 src/main/java/org/cryptomator/cryptofs/common/FileTooBigException.java delete mode 100644 src/main/java/org/cryptomator/cryptofs/common/FileUtil.java delete mode 100644 src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java diff --git a/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java b/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java index a1e9c7dd..81f955d6 100644 --- a/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java +++ b/src/main/java/org/cryptomator/cryptofs/CiphertextFilePath.java @@ -1,7 +1,6 @@ package org.cryptomator.cryptofs; import org.cryptomator.cryptofs.common.Constants; -import org.cryptomator.cryptofs.fh.InUseFile; import java.io.IOException; import java.nio.file.Path; diff --git a/src/main/java/org/cryptomator/cryptofs/common/FileTooBigException.java b/src/main/java/org/cryptomator/cryptofs/common/FileTooBigException.java deleted file mode 100644 index 15f074fc..00000000 --- a/src/main/java/org/cryptomator/cryptofs/common/FileTooBigException.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.cryptomator.cryptofs.common; - -import java.io.IOException; -import java.nio.file.Path; - -public class FileTooBigException extends IOException { - - public FileTooBigException(Path file, long currentSize, int maxSize) { - super("File %s has size %d, exceeding maximum allowed size of %d.".formatted(file, currentSize, maxSize)); - } - -} diff --git a/src/main/java/org/cryptomator/cryptofs/common/FileUtil.java b/src/main/java/org/cryptomator/cryptofs/common/FileUtil.java deleted file mode 100644 index 93ccf74a..00000000 --- a/src/main/java/org/cryptomator/cryptofs/common/FileUtil.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.cryptomator.cryptofs.common; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -public class FileUtil { - - private FileUtil() {}; - - public static byte[] readAllBytesSizeRestricted(Path target, int maxSize) throws IOException { - long size = Files.size(target); - if (size > maxSize) { - throw new FileTooBigException(target, size, maxSize); - } - return Files.readAllBytes(target); - } - -} diff --git a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java b/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java deleted file mode 100644 index 2cee0b7d..00000000 --- a/src/main/java/org/cryptomator/cryptofs/fh/InUseFile.java +++ /dev/null @@ -1,219 +0,0 @@ -package org.cryptomator.cryptofs.fh; - -import jakarta.inject.Inject; -import org.cryptomator.cryptofs.CryptoFileSystemProperties; -import org.cryptomator.cryptofs.common.Constants; -import org.cryptomator.cryptofs.common.FileTooBigException; -import org.cryptomator.cryptofs.common.FileUtil; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.Closeable; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.channels.SeekableByteChannel; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; -import java.nio.file.OpenOption; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.Properties; -import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; - -@OpenFileScoped -public class InUseFile implements Closeable { - - private static final Logger LOG = LoggerFactory.getLogger(InUseFile.class); - - private final String fileSystemOwner; - private final Properties info; - private final AtomicReference currentFilePath; - private SeekableByteChannel inUseFileChannel; - - private volatile boolean closed; - - @Inject - public InUseFile(@CurrentOpenFilePath AtomicReference currentFilePath, // - CryptoFileSystemProperties fsProps) { - this.currentFilePath = currentFilePath; - this.fileSystemOwner = (String) fsProps.getOrDefault("owner", "cryptobot"); - this.info = new Properties(); - info.put("owner", fileSystemOwner); - } - - synchronized boolean acquire() throws FileAlreadyInUseException { - var ciphertextPath = currentFilePath.get(); - var inUseFilePath = computeInUseFilePath(ciphertextPath); - var selfUseSuccessful = false; - try { - if (isInUseInternal(inUseFilePath, fileSystemOwner)) { - throw new FileAlreadyInUseException(ciphertextPath); - } - selfUseSuccessful = updateInUseFile(inUseFilePath); - } catch (FileAlreadyInUseException e) { - throw e; - } catch (NoSuchFileException e) { - LOG.debug("No in-use-file for {} found. Creating it.", ciphertextPath, e); - //TODO: delay creation with a CompletionStage (to prevent spam) - selfUseSuccessful = createInUseFile(inUseFilePath); - } catch (FileTooBigException | IllegalArgumentException e) { - LOG.info("Found invalid in-use-file for {}. Owning it.", ciphertextPath, e); - selfUseSuccessful = stealInUseFile(inUseFilePath); - } catch (IOException e) { - LOG.warn("Failed to read in-use file for {}. Ignoring it.", ciphertextPath, e); - } - return selfUseSuccessful; - } - - private boolean updateInUseFile(Path inUseFilePath) { - //TODO - return true; - } - - /** - * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running cryptofile system. - * - * @return {@code true} if the in-use-file exists, is valid, but owned by different user. Otherwise {@code false}. - */ - public static boolean isInUse(Path ciphertextPath, String owner) { - var inUseFile = computeInUseFilePath(ciphertextPath); - try { - return isInUseInternal(inUseFile, owner); - } catch (IllegalArgumentException | IOException e) { - return false; - } - } - - /** - * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running cryptofile system. - * - * @param inUseFilePath - * @param fileSystemOwner name of the filesystem owner - * @return {@code true} if the in-use-file exists, but owned by different user - * @throws IOException if the in-use-file does not exist or cannot be read - * @throws IllegalArgumentException if the in-use-file is invalid - */ - static boolean isInUseInternal(Path inUseFilePath, String fileSystemOwner) throws IOException, IllegalArgumentException { - Properties content = readInUseFile(inUseFilePath); - if (!content.get("owner").equals(fileSystemOwner)) { - //TODO: check also timestamps - return true; - } - return false; - } - - static Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { - //TODO: decryption - var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); - //TODO: convert to JSON an extract info - // for now we use properties - var props = new Properties(); - try (var stream = new ByteArrayInputStream(bytes)) { - props.load(stream); - validate(props); - return props; - } - } - - private static void validate(Properties content) throws IllegalArgumentException { - if (!content.containsKey("owner")) { - throw new IllegalArgumentException("Invalid in-use-file. Missing key \"owner\""); - } - //TODO: more keys - } - - boolean createInUseFile(Path inUseFilePath) { - try { - return writeInUseFile(inUseFilePath, Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)); - } catch (IOException e) { - LOG.warn("Failed to create in-use file for {}.", inUseFilePath, e); - return false; - } - } - - boolean stealInUseFile(Path inUseFilePath) { - try { - return writeInUseFile(inUseFilePath, Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)); - } catch (IOException e) { - LOG.warn("Failed to steal in-use file for {}.", inUseFilePath, e); - return false; - } - } - - boolean writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { - this.inUseFileChannel = Files.newByteChannel(inUseFilePath, openOptions); //TODO: delete on close? - var rawInfo = new ByteArrayOutputStream(4_000); - info.store(rawInfo, "UNENCRYPTED Cryptomator inUse file"); - //TODO: encryption - inUseFileChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); - inUseFileChannel.position(0); - return true; - } - - //for testing - void deleteInUseFile(Path inUseFilePath) throws IOException { - Files.deleteIfExists(inUseFilePath); - } - - synchronized void move(Path source) { - var target = currentFilePath.get(); - try { - Files.move(source, target); - } catch (IOException e) { - LOG.warn("Could not move in-use-file from {} to {}", source, target, e); - } - } - - - @Override - public synchronized void close() { - if (closed) { - return; - } - - //always close - this.closed = true; - var ciphertextPath = currentFilePath.get(); - if (inUseFileChannel != null) { - try { - inUseFileChannel.close(); - } catch (IOException e) { - LOG.warn("Unable to close in-use-file for {}. Must be cleaned manually.", ciphertextPath); - } - if (ciphertextPath != null) { - var inUsePath = computeInUseFilePath(currentFilePath.get()); - try { - deleteInUseFile(inUsePath); - } catch (IOException e) { - LOG.warn("Unable to delete in-use-file for {}. Must be cleaned manually.", inUsePath); - } - } - //else: will be cleaned up by {@link CryptoFileSystem#delete} - } - } - - /** - * @param p a path with a filename ending with {@value Constants#CRYPTOMATOR_FILE_SUFFIX} - * @return a sibling path with the file extension {@value Constants#INUSE_FILE_SUFFIX} - */ - public static Path computeInUseFilePath(Path p) { - var tmp = p.getFileName().toString(); - var fileName = tmp.substring(0, tmp.length() - Constants.CRYPTOMATOR_FILE_SUFFIX.length()); - return p.resolveSibling(fileName + Constants.INUSE_FILE_SUFFIX); - } - - //-- for testing only - - InUseFile(AtomicReference currentFilePath, // - String fileSystemOwner, // - SeekableByteChannel inUseFileChannel, // - Properties info) { - this.currentFilePath = currentFilePath; - this.fileSystemOwner = fileSystemOwner; - this.inUseFileChannel = inUseFileChannel; - this.info = info; - } -} diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 48d93630..f0839c5d 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -1,8 +1,7 @@ package org.cryptomator.cryptofs.inuse; import org.cryptomator.cryptofs.common.Constants; -import org.cryptomator.cryptofs.common.FileTooBigException; -import org.cryptomator.cryptofs.common.FileUtil; +import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; import org.cryptomator.cryptolib.api.Cryptor; import org.jspecify.annotations.NonNull; @@ -12,8 +11,11 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -71,11 +73,15 @@ boolean isInUseInternal(Path inUseFilePath) throws IOException, IllegalArgumentE return false; } + //TODO: test test test Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { - //TODO: decryption - var bytes = FileUtil.readAllBytesSizeRestricted(inUseFilePath, 4_000); + var bytes = ByteBuffer.allocate(cryptor.fileContentCryptor().cleartextChunkSize()); //TODO: should the inuse file size coupled to the chunk size? + try (var ch = Files.newByteChannel(inUseFilePath, StandardOpenOption.READ); + var channel = EncryptedChannels.wrapDecryptionAround(ch,cryptor)) { + channel.read(bytes); + } var props = new Properties(); - try (var stream = new ByteArrayInputStream(bytes)) { + try (var stream = new ByteArrayInputStream(bytes.array())) { props.load(stream); validate(props); return props; @@ -117,15 +123,15 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { if (isInUseInternal(inUseFilePath)) { throw new FileAlreadyInUseException(inUseFilePath); } - return RealUseToken.createWithExistingFile(inUseFilePath, owner, useTokens); + return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); } catch (FileAlreadyInUseException e) { throw new UncheckedIOException(e); //wrapped due to Map::compute method } catch (NoSuchFileException e) { LOG.debug("No in-use-file {} found. Creating it.", inUseFilePath, e); - return RealUseToken.createWithNewFile(inUseFilePath, owner, useTokens); - } catch (FileTooBigException | IllegalArgumentException e) { + return RealUseToken.createWithNewFile(inUseFilePath, owner, cryptor, useTokens); + } catch (IllegalArgumentException e) { LOG.info("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); - return RealUseToken.createWithInvalidFile(inUseFilePath, owner, useTokens); + return RealUseToken.createWithInvalidFile(inUseFilePath, owner, cryptor, useTokens); } catch (IOException e) { LOG.warn("Failed to read in-use file {}. Ignoring it.", inUseFilePath, e); throw new UncheckedIOException(e); @@ -146,6 +152,7 @@ static Path computeInUseFilePath(Path p) { //for testing RealInUseManager(String owner, Cryptor cryptor, ConcurrentMap useTokens) { this.owner = owner; + this.cryptor = cryptor; this.useTokens = useTokens; } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 227f1854..57d3c9f5 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -1,6 +1,8 @@ package org.cryptomator.cryptofs.inuse; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.common.EncryptedChannels; +import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -8,7 +10,8 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.ByteBuffer; -import java.nio.channels.SeekableByteChannel; +import java.nio.channels.ByteChannel; +import java.nio.channels.WritableByteChannel; import java.nio.file.Files; import java.nio.file.OpenOption; import java.nio.file.Path; @@ -37,37 +40,45 @@ */ public final class RealUseToken implements UseToken { - public static RealUseToken createWithNewFile(Path p, String owner, ConcurrentMap useTokens) { - return new RealUseToken(p, owner, useTokens, ActivationType.CREATE); + public static RealUseToken createWithNewFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { + return new RealUseToken(p, owner, cryptor, useTokens, ActivationType.CREATE); } - public static RealUseToken createWithExistingFile(Path p, String owner, ConcurrentMap useTokens) { - return new RealUseToken(p, owner, useTokens, ActivationType.UPDATE); + public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { + return new RealUseToken(p, owner, cryptor, useTokens, ActivationType.UPDATE); } - public static RealUseToken createWithInvalidFile(Path p, String owner, ConcurrentMap useTokens) { - return new RealUseToken(p, owner, useTokens, ActivationType.STEAL); + public static RealUseToken createWithInvalidFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { + return new RealUseToken(p, owner, cryptor, useTokens, ActivationType.STEAL); } public static RealUseToken createInvalid(Path p, ConcurrentMap useTokens) { - return new RealUseToken(p, "unused", useTokens, ActivationType.NONE); + return new RealUseToken(p, "unused", null, useTokens, ActivationType.NONE); } private static final Logger LOG = LoggerFactory.getLogger(RealUseToken.class); private final String owner; private final CompletableFuture creationTask; + private final Cryptor cryptor; private final ConcurrentMap useTokens; + private final EncryptionDecorator encWrapper; //this exists to make the class testable private final ReentrantReadWriteLock.WriteLock fileCreationSync = new ReentrantReadWriteLock().writeLock(); private volatile Path filePath; - private volatile SeekableByteChannel channel; + private volatile WritableByteChannel channel; private volatile boolean closed; - private RealUseToken(Path filePath, String owner, ConcurrentMap useTokens, ActivationType m) { + RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, ActivationType m) { + this(filePath, owner, cryptor, useTokens, m, (ch, cr) -> EncryptedChannels.wrapEncryptionAround(ch, cr)); + } + + RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, ActivationType m, EncryptionDecorator encWrapper) { this.owner = owner; this.filePath = filePath; + this.cryptor = cryptor; this.useTokens = useTokens; + this.encWrapper = encWrapper; FileOperation method = switch (m) { case STEAL -> this::stealInUseFile; case UPDATE -> this::updateInUseFile; @@ -125,16 +136,16 @@ private void updateInUseFile() throws IOException { } } + //TODO: refresh logic? void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { - this.channel = Files.newByteChannel(inUseFilePath, openOptions); + var ch = Files.newByteChannel(inUseFilePath, openOptions); + this.channel = encWrapper.wrapWithEncryption(ch, cryptor); var rawInfo = new ByteArrayOutputStream(4_000); var prop = new Properties(); prop.put("owner", owner); prop.put("since", Instant.now().toString()); - prop.store(rawInfo, "UNENCRYPTED Cryptomator inUse file"); - //TODO: encryption + prop.store(rawInfo, null); channel.write(ByteBuffer.wrap(rawInfo.toByteArray())); - channel.position(0); } @Override @@ -217,4 +228,9 @@ interface FileOperation { void execute() throws IOException; } + + interface EncryptionDecorator { + + WritableByteChannel wrapWithEncryption(ByteChannel ch, Cryptor c); + } } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 4b5ee051..554d8e7e 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -128,13 +128,13 @@ public void testCreateExistingValid() throws IOException { var token = mock(RealUseToken.class); try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { - staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", preparedMap)).thenReturn(token); + staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); doReturn(false).when(inUseSpy).isInUseInternal(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); verify(inUseSpy).isInUseInternal(inUseFilePath); - staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", preparedMap)); + staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); } } @@ -147,13 +147,13 @@ public void testCreateExistingInvalid() throws IOException { var token = mock(RealUseToken.class); try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { - staticUseTokenMock.when(() -> RealUseToken.createWithInvalidFile(inUseFilePath, "cryptobot3000", preparedMap)).thenReturn(token); + staticUseTokenMock.when(() -> RealUseToken.createWithInvalidFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); doThrow(IllegalArgumentException.class).when(inUseSpy).isInUseInternal(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); verify(inUseSpy).isInUseInternal(inUseFilePath); - staticUseTokenMock.verify(() -> RealUseToken.createWithInvalidFile(inUseFilePath, "cryptobot3000", preparedMap)); + staticUseTokenMock.verify(() -> RealUseToken.createWithInvalidFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); } } @@ -166,13 +166,13 @@ public void testCreateNotExisting() throws IOException { var token = mock(RealUseToken.class); try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { - staticUseTokenMock.when(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", preparedMap)).thenReturn(token); + staticUseTokenMock.when(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); doThrow(NoSuchFileException.class).when(inUseSpy).isInUseInternal(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); verify(inUseSpy).isInUseInternal(inUseFilePath); - staticUseTokenMock.verify(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", preparedMap)); + staticUseTokenMock.verify(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); } } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index cc0e1752..832daa19 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -2,6 +2,7 @@ import org.awaitility.Awaitility; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptolib.api.Cryptor; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; @@ -20,9 +21,16 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + public class RealUseTokenTest { private ConcurrentMap useTokens; + private Cryptor cryptor; + private RealUseToken.EncryptionDecorator encWrapper; @TempDir Path tmpDir; private WatchService watchService; @@ -32,8 +40,13 @@ public class RealUseTokenTest { @BeforeEach public void beforeEach() throws IOException { + cryptor = mock(Cryptor.class); + encWrapper = mock(RealUseToken.EncryptionDecorator.class); useTokens = new ConcurrentHashMap<>();//Mockito.mock(ConcurrentMap.class); watchService = tmpDir.getFileSystem().newWatchService(); + + doAnswer(invocation -> invocation.getArgument(0)) //just return the real file channel + .when(encWrapper).wrapWithEncryption(any(), eq(cryptor)); } @AfterEach @@ -49,7 +62,7 @@ public void afterEach() { @DisplayName("After 5 seconds of token creation, a new file is created") public void testFileCreation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); - try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); Assertions.assertTrue(Files.exists(filePath)); } @@ -64,7 +77,7 @@ public void testFileSteal() throws IOException { var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); var fileTime = Files.getLastModifiedTime(filePath); - try (var token = RealUseToken.createWithInvalidFile(filePath, "test3000", useTokens)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.UPDATE, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); var events = watchKey.pollEvents(); var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); @@ -84,7 +97,7 @@ public void testInvalid() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); - try (var token = RealUseToken.createInvalid(filePath, useTokens)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.NONE, encWrapper)) { Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); @@ -99,7 +112,7 @@ public void testFileStealFails() throws IOException { var filePath = tmpDir.resolve("inUse.file"); //file does not exist var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = RealUseToken.createWithInvalidFile(filePath, "test3000", useTokens)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.STEAL, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(token::isClosed); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); @@ -114,7 +127,7 @@ public void testTokenCloseBeforeFileOperation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { Assertions.assertTrue(Files.notExists(filePath)); } Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); @@ -130,7 +143,7 @@ public void testMoveToBefore() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { token.moveToInternal(targetPath); //no file operation after move @@ -160,15 +173,15 @@ public void testMoveToAfter() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var targetPath = tmpDir.resolve("inUse2.file"); - try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); token.moveToInternal(targetPath); // target file will be created // orginal filePath does not exist, target exists - Assertions.assertTrue(Files.notExists(filePath)); - Assertions.assertTrue(Files.exists(targetPath)); + Assertions.assertTrue(Files.notExists(filePath), "inUse.file still exists after move"); + Assertions.assertTrue(Files.exists(targetPath), "inUse2.file does not exist after move"); Assertions.assertNull(useTokens.get(filePath)); Assertions.assertNotNull(useTokens.get(targetPath)); } @@ -183,7 +196,7 @@ public void testMoveToClosed() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = RealUseToken.createWithNewFile(filePath, "test3000", useTokens)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { token.close(); Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); From 1567accfdc6af20a1dad5fe72c2bbd0624ab46bb Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 22 Sep 2025 18:56:38 +0200 Subject: [PATCH 44/95] add unit tests for readInUseFIle --- .../cryptofs/inuse/RealInUseManager.java | 17 ++-- .../cryptofs/inuse/RealInUseManagerTest.java | 98 +++++++++++++++++-- 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index f0839c5d..7e036024 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -73,22 +73,27 @@ boolean isInUseInternal(Path inUseFilePath) throws IOException, IllegalArgumentE return false; } - //TODO: test test test Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { var bytes = ByteBuffer.allocate(cryptor.fileContentCryptor().cleartextChunkSize()); //TODO: should the inuse file size coupled to the chunk size? - try (var ch = Files.newByteChannel(inUseFilePath, StandardOpenOption.READ); - var channel = EncryptedChannels.wrapDecryptionAround(ch,cryptor)) { - channel.read(bytes); + final int readBytes; + try (var ch = Files.newByteChannel(inUseFilePath, StandardOpenOption.READ); // + var channel = EncryptedChannels.wrapDecryptionAround(ch, cryptor)) { + readBytes = channel.read(bytes); } + + if (readBytes < 0) { + throw new IllegalArgumentException("Empty cleartext inUse file"); + } + var props = new Properties(); - try (var stream = new ByteArrayInputStream(bytes.array())) { + try (var stream = new ByteArrayInputStream(bytes.array(), 0, readBytes)) { props.load(stream); validate(props); return props; } } - private void validate(Properties content) throws IllegalArgumentException { + void validate(Properties content) throws IllegalArgumentException { if (!content.containsKey("owner")) { throw new IllegalArgumentException("Invalid in-use-file. Missing key \"owner\""); } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 554d8e7e..188e9e99 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -1,29 +1,34 @@ package org.cryptomator.cryptofs.inuse; +import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.FileContentCryptor; +import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentMatcher; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import java.io.IOException; +import java.io.InputStream; import java.io.UncheckedIOException; +import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; public class RealInUseManagerTest { @@ -191,9 +196,84 @@ public void testCreateFailedRead() throws IOException { verify(inUseSpy).isInUseInternal(inUseFilePath); } - //TODO: test createInvalid + @Nested + class ReadInUseFile { + + MockedStatic staticEncryptionMock; + + @BeforeEach + void beforeEach(@TempDir Path tempDir) { + inUseFilePath = tempDir.resolve("inUse.file"); + staticEncryptionMock = mockStatic(EncryptedChannels.class); + + var fileContentCryptor = mock(FileContentCryptor.class); + when(cryptor.fileContentCryptor()).thenReturn(fileContentCryptor); + when(fileContentCryptor.cleartextChunkSize()).thenReturn(42); + } + + @Test + @DisplayName("Reading existing inUse-file reads from file, convert to Properties and validates them") + void SuccessTest() throws IOException { + Files.createFile(inUseFilePath); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); + var inUseSpy = spy(inUseManager); + + MockedConstruction.MockInitializer propsMockInit = (props, context) -> { + doNothing().when(props).load((InputStream) any()); + }; + try (MockedConstruction constructorProps = mockConstruction(Properties.class, propsMockInit)) { + var decryptingChannel = mock(DecryptingReadableByteChannel.class); + doReturn(42).when(decryptingChannel).read(any()); + staticEncryptionMock.when(() -> EncryptedChannels.wrapDecryptionAround(any(), eq(cryptor))).thenReturn(decryptingChannel); + doNothing().when(inUseSpy).validate(any()); + + inUseSpy.readInUseFile(inUseFilePath); + + Properties props = constructorProps.constructed().getFirst(); + verify(decryptingChannel).read(any()); + + ArgumentMatcher hasCorrectStreamSize = s -> { + try { + return s.available() == 42; + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + verify(props).load(argThat(hasCorrectStreamSize)); + verify(inUseSpy).validate(any()); + } + } + + @Test + @DisplayName("Not existing inUse-file throws NoSuchFileException") + void notExistingFile() { + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); + var inUseSpy = spy(inUseManager); + Assertions.assertThrows(NoSuchFileException.class, () -> inUseSpy.readInUseFile(inUseFilePath)); + } + + @Test + @DisplayName("Empty inUse-file throws IllegalArgumentException") + void emptyFile() throws IOException { + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); + var inUseSpy = spy(inUseManager); + + Files.createFile(inUseFilePath); + + var decryptingChannel = mock(DecryptingReadableByteChannel.class); + doReturn(-1).when(decryptingChannel).read(any()); + staticEncryptionMock.when(() -> EncryptedChannels.wrapDecryptionAround(any(), eq(cryptor))).thenReturn(decryptingChannel); + + Assertions.assertThrows(IllegalArgumentException.class, () -> inUseSpy.readInUseFile(inUseFilePath)); + } + + @AfterEach + public void afterEach() { + staticEncryptionMock.close(); + } + } + //TODO: test validate - //TODO: test readInUseFile @AfterEach public void afterEach() { From 5517765178cc65a7111756cee51eef20f543b57a Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Mon, 22 Sep 2025 18:56:48 +0200 Subject: [PATCH 45/95] clean up --- .../java/org/cryptomator/cryptofs/common/Constants.java | 2 +- .../org/cryptomator/cryptofs/inuse/RealInUseManager.java | 2 +- .../java/org/cryptomator/cryptofs/inuse/RealUseToken.java | 6 +++--- .../cryptofs/CryptoFileChannelWriteReadIntegrationTest.java | 4 ++-- .../cryptomator/cryptofs/inuse/RealInUseManagerTest.java | 4 ++-- .../org/cryptomator/cryptofs/inuse/RealUseTokenTest.java | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 0627af07..1e7b8fa6 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -36,5 +36,5 @@ private Constants() { public static final String SEPARATOR = "/"; public static final String RECOVERY_DIR_NAME = "LOST+FOUND"; - public static final int IN_USE_DELAY_MILLIS = 5000; + public static final int INUSE_DELAY_MILLIS = 5000; } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 7e036024..f5c26bb3 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -45,7 +45,7 @@ public RealInUseManager(@NonNull String owner, Cryptor cryptor) { @Override public boolean isInUseByOthers(Path ciphertextPath) { var inUseFilePath = computeInUseFilePath(ciphertextPath); - if(useTokens.containsKey(inUseFilePath)) { + if (useTokens.containsKey(inUseFilePath)) { return false; } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 57d3c9f5..9759eafd 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -29,7 +29,7 @@ /** * Class to represent a file is "in use" by this filesystem. *

    - * The actual persistence of the "in use"-state with a file is delayed by {@value Constants#IN_USE_DELAY_MILLIS} milliseconds. + * The actual persistence of the "in use"-state with a file is delayed by {@value Constants#INUSE_DELAY_MILLIS} milliseconds. * The content of the in-use-file is a JSON containing *

  • *
      owner - name of the filesystem owner
    @@ -70,7 +70,7 @@ public static RealUseToken createInvalid(Path p, ConcurrentMap useTokens, ActivationType m) { - this(filePath, owner, cryptor, useTokens, m, (ch, cr) -> EncryptedChannels.wrapEncryptionAround(ch, cr)); + this(filePath, owner, cryptor, useTokens, m, EncryptedChannels::wrapEncryptionAround); } RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, ActivationType m, EncryptionDecorator encWrapper) { @@ -104,7 +104,7 @@ public static RealUseToken createInvalid(Path p, ConcurrentMap numberOfInUseFiles() == 1); } var numberAfterClose = numberOfInUseFiles(); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 188e9e99..09802a89 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -86,8 +86,8 @@ public void testUseByOthersException(Class exceptionClass) throws IOException { } @Test - @DisplayName("\"use\" method places puts path into map and returns token") - public void testUsePlacesPathInMap() throws FileAlreadyInUseException { + @DisplayName("\"use\" method puts path into map and returns token") + public void testUsePutsPathInMap() throws FileAlreadyInUseException { var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index 832daa19..6dccbcdf 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -35,7 +35,7 @@ public class RealUseTokenTest { Path tmpDir; private WatchService watchService; - private final static Duration FILE_OPERATION_DELAY = Duration.ofMillis(Constants.IN_USE_DELAY_MILLIS - 100); + private final static Duration FILE_OPERATION_DELAY = Duration.ofMillis(Constants.INUSE_DELAY_MILLIS - 100); private final static Duration FILE_OPERATION_MAX = FILE_OPERATION_DELAY.plusMillis(3000); @BeforeEach From fcf468abb221188e433e04730c96e9c907d83d71 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 24 Sep 2025 15:36:28 +0200 Subject: [PATCH 46/95] restrict the length of owner parameter --- .../cryptofs/CryptoFileSystemProperties.java | 7 ++++++- .../cryptomator/cryptofs/inuse/RealUseToken.java | 2 +- .../cryptofs/CryptoFileSystemPropertiesTest.java | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java index 2fe1ad33..c8f0d22f 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java @@ -383,13 +383,18 @@ public Builder withFilesystemEventConsumer(Consumer eventConsum } /** - * Sets the owner of the filesystem + * Sets the owner of the filesystem. + *

    + * The owners length must be less than or equal to 100. * * @param owner the owner string used when marking files in-use * @return this * @since 2.10.0 */ public Builder withOwner(String owner) { + if (owner.length() > 100) { + throw new IllegalArgumentException("owner must have length less than or equal to 100"); + } this.owner = owner; return this; } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 9759eafd..414bd463 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -140,7 +140,7 @@ private void updateInUseFile() throws IOException { void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { var ch = Files.newByteChannel(inUseFilePath, openOptions); this.channel = encWrapper.wrapWithEncryption(ch, cryptor); - var rawInfo = new ByteArrayOutputStream(4_000); + var rawInfo = new ByteArrayOutputStream(cryptor.fileContentCryptor().cleartextChunkSize()); var prop = new Properties(); prop.put("owner", owner); prop.put("since", Instant.now().toString()); diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java index dbaf79ae..aefca685 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java @@ -57,6 +57,22 @@ public void testSetMasterkeyFilenameAndReadonlyFlag() { ); } + @Test + public void testOwnerSizeRestriction() { + String owner1 = "\u2741".repeat(100); + String owner2 = "\u2741".repeat(101); + + Assertions.assertDoesNotThrow(() -> cryptoFileSystemProperties() // + .withKeyLoader(keyLoader) // + .withOwner(owner1) // + .build()); + Assertions.assertThrows(IllegalArgumentException.class, () -> cryptoFileSystemProperties() // + .withKeyLoader(keyLoader) // + .withOwner(owner2) // + .build()); + + } + @Test public void testFromMap() { Map map = new HashMap<>(); From 4699146805c6c8151e8d45fe16843608761f788f Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 24 Sep 2025 15:55:47 +0200 Subject: [PATCH 47/95] resolve todo --- src/main/java/org/cryptomator/cryptofs/common/Constants.java | 1 + .../java/org/cryptomator/cryptofs/inuse/RealInUseManager.java | 2 +- src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 1e7b8fa6..2b097749 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -37,4 +37,5 @@ private Constants() { public static final String SEPARATOR = "/"; public static final String RECOVERY_DIR_NAME = "LOST+FOUND"; public static final int INUSE_DELAY_MILLIS = 5000; + public static final int INUSE_CLEARTEXT_SIZE = 1000; //calculation: Create inUse properties with owner consisting of \u2741.repeat(100) and encode it. Plus an additional buffer for future entries. } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index f5c26bb3..5def01a4 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -74,7 +74,7 @@ boolean isInUseInternal(Path inUseFilePath) throws IOException, IllegalArgumentE } Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { - var bytes = ByteBuffer.allocate(cryptor.fileContentCryptor().cleartextChunkSize()); //TODO: should the inuse file size coupled to the chunk size? + var bytes = ByteBuffer.allocate(Constants.INUSE_CLEARTEXT_SIZE); final int readBytes; try (var ch = Files.newByteChannel(inUseFilePath, StandardOpenOption.READ); // var channel = EncryptedChannels.wrapDecryptionAround(ch, cryptor)) { diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 414bd463..d6612a3d 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -140,7 +140,7 @@ private void updateInUseFile() throws IOException { void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { var ch = Files.newByteChannel(inUseFilePath, openOptions); this.channel = encWrapper.wrapWithEncryption(ch, cryptor); - var rawInfo = new ByteArrayOutputStream(cryptor.fileContentCryptor().cleartextChunkSize()); + var rawInfo = new ByteArrayOutputStream(Constants.INUSE_CLEARTEXT_SIZE); var prop = new Properties(); prop.put("owner", owner); prop.put("since", Instant.now().toString()); From ca875c05082b0e25a5d7ad8c840e2f0109e9ebd3 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 26 Sep 2025 17:58:10 +0200 Subject: [PATCH 48/95] add time condition to isInUse If the update timestamp is too old, simply steal the file --- .../cryptofs/inuse/RealInUseManager.java | 45 +++++++---- .../cryptofs/inuse/RealUseToken.java | 10 +-- .../cryptomator/cryptofs/inuse/UseToken.java | 4 + .../cryptofs/inuse/RealInUseManagerTest.java | 77 +++++++++++++++---- .../cryptofs/inuse/RealUseTokenTest.java | 2 +- 5 files changed, 100 insertions(+), 38 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 5def01a4..92712cc6 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -16,6 +16,9 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -30,10 +33,11 @@ public class RealInUseManager implements InUseManager { private static final Logger LOG = LoggerFactory.getLogger(RealInUseManager.class); + private static final int REFRESH_DELAY_MINUTES = 5; private final ConcurrentMap useTokens; private final String owner; - private Cryptor cryptor; + private final Cryptor cryptor; public RealInUseManager(@NonNull String owner, Cryptor cryptor) { this.owner = owner; @@ -50,27 +54,27 @@ public boolean isInUseByOthers(Path ciphertextPath) { } try { - return isInUseInternal(inUseFilePath); + return isInUse(inUseFilePath); } catch (IllegalArgumentException | IOException e) { return false; } } /** - * Reads the in-use-file at the given path, validates it and checks if this in-use-file belongs to the running crypto filesystem. + * Reads the in-use-file at the given path, validates it and checks if + *

      + *
    • this in-use-file belongs to the running crypto filesystem and
    • + *
    • the last update time is at most 2*{@value #REFRESH_DELAY_MINUTES}
    • minutes ago + *
    . * * @param inUseFilePath * @return {@code true} if the in-use-file exists, but owned by different user * @throws IOException if the in-use-file does not exist or cannot be read * @throws IllegalArgumentException if the in-use-file is invalid */ - boolean isInUseInternal(Path inUseFilePath) throws IOException, IllegalArgumentException { + boolean isInUse(Path inUseFilePath) throws IOException, IllegalArgumentException { Properties content = readInUseFile(inUseFilePath); - if (!content.get("owner").equals(owner)) { - //TODO: check also timestamps - return true; - } - return false; + return isInUse(content); } Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { @@ -94,10 +98,23 @@ Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgument } void validate(Properties content) throws IllegalArgumentException { - if (!content.containsKey("owner")) { - throw new IllegalArgumentException("Invalid in-use-file. Missing key \"owner\""); + if (!content.containsKey(UseToken.OWNER_KEY)) { + throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.OWNER_KEY)); + } + if (!content.containsKey(UseToken.LASTUPDATED_KEY)) { + throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.LASTUPDATED_KEY)); + } + } + + boolean isInUse(Properties content) { + if (owner.equals(content.get(UseToken.OWNER_KEY))) { + return false; } - //TODO: more keys + + var lastUpdated = Instant.parse((String) content.get(UseToken.LASTUPDATED_KEY)); + var timeSinceLastUpdate = Duration.between(lastUpdated, Instant.now()); + var threshold = Duration.of(2 * REFRESH_DELAY_MINUTES, ChronoUnit.MINUTES); + return timeSinceLastUpdate.compareTo(threshold) < 0; } /** @@ -125,7 +142,7 @@ public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { try { //TODO: performance idea: cache the result in a short lived cache (e.g. 5 seconds) - if (isInUseInternal(inUseFilePath)) { + if (isInUse(inUseFilePath)) { throw new FileAlreadyInUseException(inUseFilePath); } return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); @@ -136,7 +153,7 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { return RealUseToken.createWithNewFile(inUseFilePath, owner, cryptor, useTokens); } catch (IllegalArgumentException e) { LOG.info("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); - return RealUseToken.createWithInvalidFile(inUseFilePath, owner, cryptor, useTokens); + return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); } catch (IOException e) { LOG.warn("Failed to read in-use file {}. Ignoring it.", inUseFilePath, e); throw new UncheckedIOException(e); diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index d6612a3d..65994fab 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -45,10 +45,6 @@ public static RealUseToken createWithNewFile(Path p, String owner, Cryptor crypt } public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { - return new RealUseToken(p, owner, cryptor, useTokens, ActivationType.UPDATE); - } - - public static RealUseToken createWithInvalidFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { return new RealUseToken(p, owner, cryptor, useTokens, ActivationType.STEAL); } @@ -81,7 +77,6 @@ public static RealUseToken createInvalid(Path p, ConcurrentMap this::stealInUseFile; - case UPDATE -> this::updateInUseFile; case CREATE -> this::createInUseFile; case NONE -> () -> {}; }; @@ -142,8 +137,8 @@ void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOEx this.channel = encWrapper.wrapWithEncryption(ch, cryptor); var rawInfo = new ByteArrayOutputStream(Constants.INUSE_CLEARTEXT_SIZE); var prop = new Properties(); - prop.put("owner", owner); - prop.put("since", Instant.now().toString()); + prop.put(UseToken.OWNER_KEY, owner); + prop.put(UseToken.LASTUPDATED_KEY, Instant.now().toString()); prop.store(rawInfo, null); channel.write(ByteBuffer.wrap(rawInfo.toByteArray())); } @@ -217,7 +212,6 @@ public void close() { } enum ActivationType { - UPDATE, CREATE, STEAL, NONE; diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java index 4c7feea4..4eb1f9d9 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java @@ -26,4 +26,8 @@ public boolean isClosed() { } } + + //fields + String LASTUPDATED_KEY = "lastUpdated"; + String OWNER_KEY = "owner"; } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 09802a89..49daf7ce 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -24,6 +24,8 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; @@ -57,7 +59,7 @@ public void testUseByOthersWithExistingToken() throws IOException { var result = inUseSpy.isInUseByOthers(ciphertextPath); Assertions.assertFalse(result); - verify(inUseSpy, never()).isInUseInternal(inUseFilePath); + verify(inUseSpy, never()).isInUse(inUseFilePath); } @Test @@ -65,11 +67,11 @@ public void testUseByOthersWithExistingToken() throws IOException { public void testUseByOthers() throws IOException { var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); - doReturn(true).when(inUseSpy).isInUseInternal(inUseFilePath); + doReturn(true).when(inUseSpy).isInUse(inUseFilePath); inUseSpy.isInUseByOthers(ciphertextPath); - verify(inUseSpy).isInUseInternal(inUseFilePath); + verify(inUseSpy).isInUse(inUseFilePath); } @ParameterizedTest @@ -78,11 +80,11 @@ public void testUseByOthers() throws IOException { public void testUseByOthersException(Class exceptionClass) throws IOException { var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); - doThrow(exceptionClass).when(inUseSpy).isInUseInternal(inUseFilePath); + doThrow(exceptionClass).when(inUseSpy).isInUse(inUseFilePath); var result = Assertions.assertDoesNotThrow(() -> inUseSpy.isInUseByOthers(ciphertextPath)); Assertions.assertFalse(result); - verify(inUseSpy).isInUseInternal(inUseFilePath); + verify(inUseSpy).isInUse(inUseFilePath); } @Test @@ -134,11 +136,11 @@ public void testCreateExistingValid() throws IOException { try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); - doReturn(false).when(inUseSpy).isInUseInternal(inUseFilePath); + doReturn(false).when(inUseSpy).isInUse(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); - verify(inUseSpy).isInUseInternal(inUseFilePath); + verify(inUseSpy).isInUse(inUseFilePath); staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); } } @@ -152,13 +154,13 @@ public void testCreateExistingInvalid() throws IOException { var token = mock(RealUseToken.class); try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { - staticUseTokenMock.when(() -> RealUseToken.createWithInvalidFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); - doThrow(IllegalArgumentException.class).when(inUseSpy).isInUseInternal(inUseFilePath); + staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); + doThrow(IllegalArgumentException.class).when(inUseSpy).isInUse(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); - verify(inUseSpy).isInUseInternal(inUseFilePath); - staticUseTokenMock.verify(() -> RealUseToken.createWithInvalidFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); + verify(inUseSpy).isInUse(inUseFilePath); + staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); } } @@ -172,11 +174,11 @@ public void testCreateNotExisting() throws IOException { try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { staticUseTokenMock.when(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); - doThrow(NoSuchFileException.class).when(inUseSpy).isInUseInternal(inUseFilePath); + doThrow(NoSuchFileException.class).when(inUseSpy).isInUse(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); - verify(inUseSpy).isInUseInternal(inUseFilePath); + verify(inUseSpy).isInUse(inUseFilePath); staticUseTokenMock.verify(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); } } @@ -188,17 +190,19 @@ public void testCreateFailedRead() throws IOException { var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); var inUseSpy = spy(inUseManager); - doReturn(true).when(inUseSpy).isInUseInternal(inUseFilePath); + doReturn(true).when(inUseSpy).isInUse(inUseFilePath); var actualException = Assertions.assertThrows(UncheckedIOException.class, () -> inUseSpy.createInternal(inUseFilePath)); Assertions.assertInstanceOf(FileAlreadyInUseException.class, actualException.getCause()); - verify(inUseSpy).isInUseInternal(inUseFilePath); + verify(inUseSpy).isInUse(inUseFilePath); } @Nested class ReadInUseFile { + //TODO: update tests for timestamp check + MockedStatic staticEncryptionMock; @BeforeEach @@ -273,6 +277,49 @@ public void afterEach() { } } + @Nested + class IsInUseProperties { + + @Test + @DisplayName("If the inUse properties have the same owner as the fs, return false") + void hasSameOwner() { + var props = new Properties(); + props.put(UseToken.OWNER_KEY, "cryptobot3000"); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); + + var result = inUseManager.isInUse(props); + + Assertions.assertFalse(result, "isInUse returns true, but the owner is the same!"); + } + + @Test + @DisplayName("If the inUse properties have the lastUpdated timestamp below threshold, return true") + void hasDifferentOwnerLastUpdatedBelowTreshold() { + var props = new Properties(); + props.put(UseToken.OWNER_KEY, "bob"); + props.put(UseToken.LASTUPDATED_KEY, Instant.now().minus(3, ChronoUnit.MINUTES).toString()); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); + + var result = inUseManager.isInUse(props); + + Assertions.assertTrue(result, "isInUse returns false, but lastUpdated is below threshold!"); + } + + @Test + @DisplayName("If the inUse properties have the lastUpdated timestamp above threshold, return true") + void hasDifferentOwnerLastUpdatedAboveThreshold() { + var props = new Properties(); + props.put(UseToken.OWNER_KEY, "bob"); + props.put(UseToken.LASTUPDATED_KEY, Instant.now().minus(20, ChronoUnit.MINUTES).toString()); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor); + + var result = inUseManager.isInUse(props); + + Assertions.assertFalse(result, "isInUse returns true, but lastUpdated is above threshold!"); + } + + } + //TODO: test validate @AfterEach diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index 6dccbcdf..43073e0c 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -77,7 +77,7 @@ public void testFileSteal() throws IOException { var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); var fileTime = Files.getLastModifiedTime(filePath); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.UPDATE, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.STEAL, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); var events = watchKey.pollEvents(); var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); From cb07aa974c47df7ac84699f36054a35a957c7fd9 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 26 Sep 2025 18:00:11 +0200 Subject: [PATCH 49/95] move validate to isInUse method --- .../java/org/cryptomator/cryptofs/inuse/RealInUseManager.java | 2 +- .../org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 92712cc6..fa58fa10 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -74,6 +74,7 @@ public boolean isInUseByOthers(Path ciphertextPath) { */ boolean isInUse(Path inUseFilePath) throws IOException, IllegalArgumentException { Properties content = readInUseFile(inUseFilePath); + validate(content); return isInUse(content); } @@ -92,7 +93,6 @@ Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgument var props = new Properties(); try (var stream = new ByteArrayInputStream(bytes.array(), 0, readBytes)) { props.load(stream); - validate(props); return props; } } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 49daf7ce..621a5913 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -229,7 +229,6 @@ void SuccessTest() throws IOException { var decryptingChannel = mock(DecryptingReadableByteChannel.class); doReturn(42).when(decryptingChannel).read(any()); staticEncryptionMock.when(() -> EncryptedChannels.wrapDecryptionAround(any(), eq(cryptor))).thenReturn(decryptingChannel); - doNothing().when(inUseSpy).validate(any()); inUseSpy.readInUseFile(inUseFilePath); @@ -244,7 +243,6 @@ void SuccessTest() throws IOException { } }; verify(props).load(argThat(hasCorrectStreamSize)); - verify(inUseSpy).validate(any()); } } From c2edfbcd7d9ef22a0034ad14cc4417517c944ce0 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 30 Sep 2025 17:06:52 +0200 Subject: [PATCH 50/95] move inUse exception to correct package --- .../java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java | 2 +- .../cryptofs/{fh => inuse}/FileAlreadyInUseException.java | 2 +- src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java | 1 - .../java/org/cryptomator/cryptofs/inuse/RealInUseManager.java | 1 - .../java/org/cryptomator/cryptofs/inuse/StubInUseManager.java | 1 - .../java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java | 2 +- .../java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java | 1 + .../org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java | 1 - 8 files changed, 4 insertions(+), 7 deletions(-) rename src/main/java/org/cryptomator/cryptofs/{fh => inuse}/FileAlreadyInUseException.java (84%) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index b60cd90a..a54343e7 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -22,7 +22,7 @@ import org.cryptomator.cryptofs.dir.DirectoryStreamFilters; import org.cryptomator.cryptofs.event.FileIsInUseEvent; import org.cryptomator.cryptofs.event.FilesystemEvent; -import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptofs.inuse.FileAlreadyInUseException; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptofs.inuse.InUseManager; import org.cryptomator.cryptolib.api.Cryptor; diff --git a/src/main/java/org/cryptomator/cryptofs/fh/FileAlreadyInUseException.java b/src/main/java/org/cryptomator/cryptofs/inuse/FileAlreadyInUseException.java similarity index 84% rename from src/main/java/org/cryptomator/cryptofs/fh/FileAlreadyInUseException.java rename to src/main/java/org/cryptomator/cryptofs/inuse/FileAlreadyInUseException.java index c5ff3d83..713fb743 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/FileAlreadyInUseException.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/FileAlreadyInUseException.java @@ -1,4 +1,4 @@ -package org.cryptomator.cryptofs.fh; +package org.cryptomator.cryptofs.inuse; import java.nio.file.FileSystemException; import java.nio.file.Path; diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index 3b140f2c..dde2fc4e 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -1,6 +1,5 @@ package org.cryptomator.cryptofs.inuse; -import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; import java.io.IOException; import java.nio.file.Path; diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index fa58fa10..da45ca43 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -2,7 +2,6 @@ import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.EncryptedChannels; -import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; import org.cryptomator.cryptolib.api.Cryptor; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java index 33a99f77..02f58db7 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java @@ -1,6 +1,5 @@ package org.cryptomator.cryptofs.inuse; -import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; import java.io.IOException; import java.nio.file.Path; diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 647ff636..a05d250f 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -11,7 +11,7 @@ import org.cryptomator.cryptofs.dir.DirectoryStreamFactory; import org.cryptomator.cryptofs.event.FileIsInUseEvent; import org.cryptomator.cryptofs.event.FilesystemEvent; -import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; +import org.cryptomator.cryptofs.inuse.FileAlreadyInUseException; import org.cryptomator.cryptofs.fh.OpenCryptoFile; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptofs.fh.OpenCryptoFiles.TwoPhaseMove; diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index 10ba3842..7fc9b25e 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -6,6 +6,7 @@ import org.cryptomator.cryptofs.ReadonlyFlag; import org.cryptomator.cryptofs.ch.ChannelComponent; import org.cryptomator.cryptofs.ch.CleartextFileChannel; +import org.cryptomator.cryptofs.inuse.FileAlreadyInUseException; import org.cryptomator.cryptofs.inuse.InUseManager; import org.cryptomator.cryptofs.inuse.UseToken; import org.cryptomator.cryptolib.api.Cryptor; diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 621a5913..21a11ecb 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -1,7 +1,6 @@ package org.cryptomator.cryptofs.inuse; import org.cryptomator.cryptofs.common.EncryptedChannels; -import org.cryptomator.cryptofs.fh.FileAlreadyInUseException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileContentCryptor; import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; From 56305b299e505417ac96a3d6bc89db7d0de1e069 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 30 Sep 2025 17:09:26 +0200 Subject: [PATCH 51/95] add method to ignoreOwnership to InUseManager API * to ignore existing inUse file for a path * ignoring is time limited --- .../cryptofs/inuse/InUseManager.java | 17 ++++++---- .../cryptofs/inuse/RealInUseManager.java | 25 ++++++++++++-- .../cryptofs/inuse/StubInUseManager.java | 13 ------- .../cryptofs/inuse/RealInUseManagerTest.java | 34 +++++++++++++++---- 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index dde2fc4e..3613ad02 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -1,16 +1,19 @@ package org.cryptomator.cryptofs.inuse; - -import java.io.IOException; import java.nio.file.Path; -/** - * Factory for - */ public interface InUseManager { - boolean isInUseByOthers(Path ciphertextPath); + default boolean isInUseByOthers(Path ciphertextPath) { + return false; + } + + default UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { + return UseToken.INIT_TOKEN; + } + + default void ignoreOwnership(Path ciphertextPath) { - UseToken use(Path ciphertextPath) throws FileAlreadyInUseException; + } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index da45ca43..4cb8206c 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -1,5 +1,7 @@ package org.cryptomator.cryptofs.inuse; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptolib.api.Cryptor; @@ -21,6 +23,7 @@ import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; /** * Management object for the in-use-state of encrypted files. @@ -35,6 +38,7 @@ public class RealInUseManager implements InUseManager { private static final int REFRESH_DELAY_MINUTES = 5; private final ConcurrentMap useTokens; + private final Cache ignoredInUseFiles; private final String owner; private final Cryptor cryptor; @@ -42,6 +46,10 @@ public RealInUseManager(@NonNull String owner, Cryptor cryptor) { this.owner = owner; this.cryptor = cryptor; this.useTokens = new ConcurrentHashMap<>(); + this.ignoredInUseFiles = Caffeine.newBuilder() // + .expireAfterWrite(2, TimeUnit.MINUTES) //Do not keep the mark too long + .maximumSize(100) // + .build(); } @@ -64,6 +72,7 @@ public boolean isInUseByOthers(Path ciphertextPath) { *
      *
    • this in-use-file belongs to the running crypto filesystem and
    • *
    • the last update time is at most 2*{@value #REFRESH_DELAY_MINUTES}
    • minutes ago + *
    • the in-use-file is currently not ignored
    • *
    . * * @param inUseFilePath @@ -72,6 +81,10 @@ public boolean isInUseByOthers(Path ciphertextPath) { * @throws IllegalArgumentException if the in-use-file is invalid */ boolean isInUse(Path inUseFilePath) throws IOException, IllegalArgumentException { + if (ignoredInUseFiles.getIfPresent(inUseFilePath) != null) { + return false; + } + Properties content = readInUseFile(inUseFilePath); validate(content); return isInUse(content); @@ -144,6 +157,7 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { if (isInUse(inUseFilePath)) { throw new FileAlreadyInUseException(inUseFilePath); } + ignoredInUseFiles.invalidate(inUseFilePath); return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); } catch (FileAlreadyInUseException e) { throw new UncheckedIOException(e); //wrapped due to Map::compute method @@ -155,10 +169,16 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); } catch (IOException e) { LOG.warn("Failed to read in-use file {}. Ignoring it.", inUseFilePath, e); - throw new UncheckedIOException(e); + throw new UncheckedIOException(e); //wrapped due to Map::compute method } } + @Override + public void ignoreOwnership(Path ciphertextPath) { + var inUseFilePath = computeInUseFilePath(ciphertextPath); + ignoredInUseFiles.put(inUseFilePath, Boolean.TRUE); + } + /** * @param p a path with a filename ending with {@value Constants#CRYPTOMATOR_FILE_SUFFIX} * @return a sibling path with the file extension {@value Constants#INUSE_FILE_SUFFIX} @@ -171,9 +191,10 @@ static Path computeInUseFilePath(Path p) { //for testing - RealInUseManager(String owner, Cryptor cryptor, ConcurrentMap useTokens) { + RealInUseManager(String owner, Cryptor cryptor, ConcurrentMap useTokens, Cache ignoredInUseFiles) { this.owner = owner; this.cryptor = cryptor; this.useTokens = useTokens; + this.ignoredInUseFiles = ignoredInUseFiles; } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java index 02f58db7..8af2a1f5 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java @@ -1,18 +1,5 @@ package org.cryptomator.cryptofs.inuse; - -import java.io.IOException; -import java.nio.file.Path; - public class StubInUseManager implements InUseManager { - @Override - public boolean isInUseByOthers(Path ciphertextPath) { - return false; - } - - @Override - public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { - return UseToken.INIT_TOKEN; - } } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 21a11ecb..4131f427 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -1,5 +1,6 @@ package org.cryptomator.cryptofs.inuse; +import com.github.benmanes.caffeine.cache.Cache; import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileContentCryptor; @@ -52,7 +53,8 @@ public void beforeEach() { public void testUseByOthersWithExistingToken() throws IOException { var preparedMap = new ConcurrentHashMap(); preparedMap.put(inUseFilePath, mock(RealUseToken.class)); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); + var filesMarkedForStealing = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing); var inUseSpy = spy(inUseManager); var result = inUseSpy.isInUseByOthers(ciphertextPath); @@ -87,7 +89,7 @@ public void testUseByOthersException(Class exceptionClass) throws IOException { } @Test - @DisplayName("\"use\" method puts path into map and returns token") + @DisplayName("\"use\" method puts path into map and returns token") public void testUsePutsPathInMap() throws FileAlreadyInUseException { var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); @@ -129,7 +131,9 @@ public void testUseClosedToken() throws FileAlreadyInUseException { @DisplayName("Create internal with existing in-use-file") public void testCreateExistingValid() throws IOException { var preparedMap = new ConcurrentHashMap(); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); + var ignoredInUseFiles = mock(Cache.class); + doNothing().when(ignoredInUseFiles).invalidate(inUseFilePath); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -140,6 +144,7 @@ public void testCreateExistingValid() throws IOException { var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); verify(inUseSpy).isInUse(inUseFilePath); + verify(ignoredInUseFiles).invalidate(inUseFilePath); staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); } } @@ -148,7 +153,8 @@ public void testCreateExistingValid() throws IOException { @DisplayName("Create internal with INVALID in-use-file") public void testCreateExistingInvalid() throws IOException { var preparedMap = new ConcurrentHashMap(); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); + var ignoredFiles = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -167,7 +173,8 @@ public void testCreateExistingInvalid() throws IOException { @DisplayName("Create internal with NOT existing in-use-file") public void testCreateNotExisting() throws IOException { var preparedMap = new ConcurrentHashMap(); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); + var ignoredFiles = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -186,7 +193,8 @@ public void testCreateNotExisting() throws IOException { @DisplayName("Create internal throws UncheckedIO(FileAlreadyInUse) exception") public void testCreateFailedRead() throws IOException { var preparedMap = new ConcurrentHashMap(); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap); + var ignoredFiles = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles); var inUseSpy = spy(inUseManager); doReturn(true).when(inUseSpy).isInUse(inUseFilePath); @@ -317,6 +325,20 @@ void hasDifferentOwnerLastUpdatedAboveThreshold() { } + @Test + @DisplayName("isInUse checks ignoredCache") + void isInUseChecksIgnoredCache() throws IOException { + var preparedMap = new ConcurrentHashMap(); + var ignoredInUseFiles = mock(Cache.class); + doReturn(Boolean.TRUE).when(ignoredInUseFiles).getIfPresent(inUseFilePath); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles); + + var result = inUseManager.isInUse(inUseFilePath); + + Assertions.assertFalse(result); + verify(ignoredInUseFiles).getIfPresent(inUseFilePath); + } + //TODO: test validate @AfterEach From 71cc4d7a6e6b637b15988f9d2a993f6923d5f945 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 10 Oct 2025 13:22:01 +0200 Subject: [PATCH 52/95] add method to ignoreInUse file and cache useInfo for short time --- .../cryptofs/common/CacheUtils.java | 57 +++++++++++++++++++ .../cryptofs/inuse/InUseManager.java | 19 ++++++- .../cryptofs/inuse/RealInUseManager.java | 44 ++++++++++---- .../cryptofs/inuse/RealInUseManagerTest.java | 56 +++++++++++------- 4 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/common/CacheUtils.java diff --git a/src/main/java/org/cryptomator/cryptofs/common/CacheUtils.java b/src/main/java/org/cryptomator/cryptofs/common/CacheUtils.java new file mode 100644 index 00000000..a154fe07 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/common/CacheUtils.java @@ -0,0 +1,57 @@ +package org.cryptomator.cryptofs.common; + +import com.github.benmanes.caffeine.cache.Cache; + +import java.io.IOException; +import java.io.UncheckedIOException; + +public abstract class CacheUtils { + + private CacheUtils() {} + + /** + * Helper function for caches with IO loading functions. + *

    + * This method implements the famous workaround for (checked) IOExceptions in cache loading functions: + * {@code + * try { + * cache.get(key, k -> { + * try { + * return functionThrowingIOException(k) + * } catch (IOException e) { + * throw UncheckedIOException(e); + * } + * } + * } catch (UncheckedIOException e) { + * throw e.getCause() + * } + * } + * + * @param key The key to load from the cache + * @param cache The cache to get the value + * @param loadFunction The load function which can throw an IOException + * @return the cached value or null, if the loading function returns it. + * @param The type of keys used in the cache + * @param the type of values used in the cache + * @throws IOException if the loading function throws an IOException + */ + public static V getWithIOWrapped(K key, Cache cache, IOFunction loadFunction) throws IOException { + try { + return cache.get(key, k -> { + try { + return loadFunction.apply(k); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + + @FunctionalInterface + public interface IOFunction { + + R apply(T t) throws IOException; + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index 3613ad02..befe06e0 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -1,6 +1,8 @@ package org.cryptomator.cryptofs.inuse; import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; public interface InUseManager { @@ -8,11 +10,26 @@ default boolean isInUseByOthers(Path ciphertextPath) { return false; } + /** + * Get information about the usage info of a file. + * + * @param ciphertextPath Path to the ciphertext file for which the {@link UseInfo} is collected + * @return Optional containing {@link UseInfo}. If there is no usage information, an empty Optional is returned. + */ + default Optional getUseInfo(Path ciphertextPath) { + return Optional.empty(); + } + + default UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { return UseToken.INIT_TOKEN; } - default void ignoreOwnership(Path ciphertextPath) { + default void ignoreInUse(Path ciphertextPath) { + + } + + record UseInfo(String owner, Instant lastUpdated) { } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 4cb8206c..77e50e09 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -2,6 +2,7 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import org.cryptomator.cryptofs.common.CacheUtils; import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptolib.api.Cryptor; @@ -19,7 +20,9 @@ import java.nio.file.StandardOpenOption; import java.time.Duration; import java.time.Instant; +import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; +import java.util.Optional; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -38,6 +41,7 @@ public class RealInUseManager implements InUseManager { private static final int REFRESH_DELAY_MINUTES = 5; private final ConcurrentMap useTokens; + private final Cache useInfoCache; private final Cache ignoredInUseFiles; private final String owner; private final Cryptor cryptor; @@ -50,6 +54,8 @@ public RealInUseManager(@NonNull String owner, Cryptor cryptor) { .expireAfterWrite(2, TimeUnit.MINUTES) //Do not keep the mark too long .maximumSize(100) // .build(); + this.useInfoCache = Caffeine.newBuilder() // + .expireAfterWrite(5, TimeUnit.SECONDS).maximumSize(1000).build(); } @@ -85,9 +91,12 @@ boolean isInUse(Path inUseFilePath) throws IOException, IllegalArgumentException return false; } - Properties content = readInUseFile(inUseFilePath); - validate(content); - return isInUse(content); + var info = CacheUtils.getWithIOWrapped(inUseFilePath, useInfoCache, p -> { + var content = readInUseFile(inUseFilePath); + return validate(content); + }); + + return isInUse(info); } Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { @@ -109,26 +118,39 @@ Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgument } } - void validate(Properties content) throws IllegalArgumentException { + //TODO: test + UseInfo validate(Properties content) throws IllegalArgumentException { if (!content.containsKey(UseToken.OWNER_KEY)) { throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.OWNER_KEY)); } if (!content.containsKey(UseToken.LASTUPDATED_KEY)) { throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.LASTUPDATED_KEY)); } + var stringTime = (String) content.get(UseToken.LASTUPDATED_KEY); + try { + var lastUpdated = Instant.parse(stringTime); + return new UseInfo(owner, lastUpdated); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid in-use-file. Unable to parse content %s of key %s as UTC timestamp.".formatted(stringTime, UseToken.LASTUPDATED_KEY), e); + } } - boolean isInUse(Properties content) { - if (owner.equals(content.get(UseToken.OWNER_KEY))) { + boolean isInUse(UseInfo useInfo) { + if (owner.equals(useInfo.owner())) { return false; } - var lastUpdated = Instant.parse((String) content.get(UseToken.LASTUPDATED_KEY)); - var timeSinceLastUpdate = Duration.between(lastUpdated, Instant.now()); + var timeSinceLastUpdate = Duration.between(useInfo.lastUpdated(), Instant.now()); var threshold = Duration.of(2 * REFRESH_DELAY_MINUTES, ChronoUnit.MINUTES); return timeSinceLastUpdate.compareTo(threshold) < 0; } + @Override + public Optional getUseInfo(Path ciphertextPath) { + var inUseFilePath = computeInUseFilePath(ciphertextPath); + return Optional.ofNullable(useInfoCache.getIfPresent(inUseFilePath)); + } + /** * Marks the given ciphertext path as in-use by this filesystem. * @@ -153,7 +175,6 @@ public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { try { - //TODO: performance idea: cache the result in a short lived cache (e.g. 5 seconds) if (isInUse(inUseFilePath)) { throw new FileAlreadyInUseException(inUseFilePath); } @@ -174,7 +195,7 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { } @Override - public void ignoreOwnership(Path ciphertextPath) { + public void ignoreInUse(Path ciphertextPath) { var inUseFilePath = computeInUseFilePath(ciphertextPath); ignoredInUseFiles.put(inUseFilePath, Boolean.TRUE); } @@ -191,10 +212,11 @@ static Path computeInUseFilePath(Path p) { //for testing - RealInUseManager(String owner, Cryptor cryptor, ConcurrentMap useTokens, Cache ignoredInUseFiles) { + RealInUseManager(String owner, Cryptor cryptor, ConcurrentMap useTokens, Cache ignoredInUseFiles, Cache useInfoCache) { this.owner = owner; this.cryptor = cryptor; this.useTokens = useTokens; this.ignoredInUseFiles = ignoredInUseFiles; + this.useInfoCache = useInfoCache; } } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 4131f427..68bdac27 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -54,7 +54,8 @@ public void testUseByOthersWithExistingToken() throws IOException { var preparedMap = new ConcurrentHashMap(); preparedMap.put(inUseFilePath, mock(RealUseToken.class)); var filesMarkedForStealing = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing); + var useInfoCache = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing, useInfoCache); var inUseSpy = spy(inUseManager); var result = inUseSpy.isInUseByOthers(ciphertextPath); @@ -133,7 +134,8 @@ public void testCreateExistingValid() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredInUseFiles = mock(Cache.class); doNothing().when(ignoredInUseFiles).invalidate(inUseFilePath); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles); + var useInfoCache = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -154,7 +156,8 @@ public void testCreateExistingValid() throws IOException { public void testCreateExistingInvalid() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles); + var useInfoCache = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -174,7 +177,8 @@ public void testCreateExistingInvalid() throws IOException { public void testCreateNotExisting() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles); + var useInfoCache = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -194,7 +198,8 @@ public void testCreateNotExisting() throws IOException { public void testCreateFailedRead() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles); + var useInfoCache = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache); var inUseSpy = spy(inUseManager); doReturn(true).when(inUseSpy).isInUse(inUseFilePath); @@ -208,8 +213,6 @@ public void testCreateFailedRead() throws IOException { @Nested class ReadInUseFile { - //TODO: update tests for timestamp check - MockedStatic staticEncryptionMock; @BeforeEach @@ -283,16 +286,15 @@ public void afterEach() { } @Nested - class IsInUseProperties { + class IsInUseUseInfo { @Test @DisplayName("If the inUse properties have the same owner as the fs, return false") void hasSameOwner() { - var props = new Properties(); - props.put(UseToken.OWNER_KEY, "cryptobot3000"); + var useInfo = new InUseManager.UseInfo("cryptobot3000", Instant.now()); var inUseManager = new RealInUseManager("cryptobot3000", cryptor); - var result = inUseManager.isInUse(props); + var result = inUseManager.isInUse(useInfo); Assertions.assertFalse(result, "isInUse returns true, but the owner is the same!"); } @@ -300,12 +302,10 @@ void hasSameOwner() { @Test @DisplayName("If the inUse properties have the lastUpdated timestamp below threshold, return true") void hasDifferentOwnerLastUpdatedBelowTreshold() { - var props = new Properties(); - props.put(UseToken.OWNER_KEY, "bob"); - props.put(UseToken.LASTUPDATED_KEY, Instant.now().minus(3, ChronoUnit.MINUTES).toString()); + var useInfo = new InUseManager.UseInfo("bob",Instant.now().minus(3, ChronoUnit.MINUTES)); var inUseManager = new RealInUseManager("cryptobot3000", cryptor); - var result = inUseManager.isInUse(props); + var result = inUseManager.isInUse(useInfo); Assertions.assertTrue(result, "isInUse returns false, but lastUpdated is below threshold!"); } @@ -313,12 +313,10 @@ void hasDifferentOwnerLastUpdatedBelowTreshold() { @Test @DisplayName("If the inUse properties have the lastUpdated timestamp above threshold, return true") void hasDifferentOwnerLastUpdatedAboveThreshold() { - var props = new Properties(); - props.put(UseToken.OWNER_KEY, "bob"); - props.put(UseToken.LASTUPDATED_KEY, Instant.now().minus(20, ChronoUnit.MINUTES).toString()); + var useInfo = new InUseManager.UseInfo("bob",Instant.now().minus(20, ChronoUnit.MINUTES)); var inUseManager = new RealInUseManager("cryptobot3000", cryptor); - var result = inUseManager.isInUse(props); + var result = inUseManager.isInUse(useInfo); Assertions.assertFalse(result, "isInUse returns true, but lastUpdated is above threshold!"); } @@ -330,8 +328,9 @@ void hasDifferentOwnerLastUpdatedAboveThreshold() { void isInUseChecksIgnoredCache() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredInUseFiles = mock(Cache.class); + var useInfoCache = mock(Cache.class); doReturn(Boolean.TRUE).when(ignoredInUseFiles).getIfPresent(inUseFilePath); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache); var result = inUseManager.isInUse(inUseFilePath); @@ -339,6 +338,23 @@ void isInUseChecksIgnoredCache() throws IOException { verify(ignoredInUseFiles).getIfPresent(inUseFilePath); } + @Test + @DisplayName("isInUse checks useInfo cache") + void isInUseChecksUseInfoCache() throws IOException { + var preparedMap = new ConcurrentHashMap(); + var ignoredInUseFiles = mock(Cache.class); + doReturn(null).when(ignoredInUseFiles).getIfPresent(inUseFilePath); + var useInfoCache = mock(Cache.class); + var useInfo = new InUseManager.UseInfo("bob", Instant.now()); + doReturn(useInfo).when(useInfoCache).get(eq(inUseFilePath), any()); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache); + + var result = inUseManager.isInUse(inUseFilePath); + + Assertions.assertTrue(result); + verify(useInfoCache).get(eq(inUseFilePath), any()); + } + //TODO: test validate @AfterEach From dcd6dabd0a21233be3e1cc7796c6742e463445ae Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 10 Oct 2025 13:22:18 +0200 Subject: [PATCH 53/95] add documentation to InUseManager interface --- .../cryptofs/inuse/InUseManager.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index befe06e0..98cce8b3 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -4,8 +4,22 @@ import java.time.Instant; import java.util.Optional; +/** + * A file is considered in use, if: + *

      + *
    • an in-use-file for the given file exists
    • + *
    • that in-use-file belongs to the running crypto filesystem
    • + *
    • the last update time is at most X minutes ago
    • + *
    . + */ public interface InUseManager { + /** + * Checks if the given ciphertext path is used by others. + * + * @param ciphertextPath Path to the ciphertext file for which the usage is checked + * @return {@code true} if the file is used by others. {@code false} otherwise + */ default boolean isInUseByOthers(Path ciphertextPath) { return false; } @@ -21,6 +35,13 @@ default Optional getUseInfo(Path ciphertextPath) { } + /** + * Marks the given ciphertextpath as used. + * + * @param ciphertextPath Path to the ciphertext file which should be marked as used. + * @return A {@link UseToken} representing a use-ship for this file + * @throws FileAlreadyInUseException if the file is already in use by a different owner + */ default UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { return UseToken.INIT_TOKEN; } From c79f1e47953cbe18b05c592ca543c8717efc6a11 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 10 Oct 2025 13:31:58 +0200 Subject: [PATCH 54/95] refactor inner record UseInfo to own class file --- .../java/org/cryptomator/cryptofs/inuse/InUseManager.java | 5 ----- src/main/java/org/cryptomator/cryptofs/inuse/UseInfo.java | 7 +++++++ .../cryptomator/cryptofs/inuse/RealInUseManagerTest.java | 8 ++++---- 3 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/inuse/UseInfo.java diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index 98cce8b3..0f577757 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -1,7 +1,6 @@ package org.cryptomator.cryptofs.inuse; import java.nio.file.Path; -import java.time.Instant; import java.util.Optional; /** @@ -50,8 +49,4 @@ default void ignoreInUse(Path ciphertextPath) { } - record UseInfo(String owner, Instant lastUpdated) { - - } - } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/UseInfo.java b/src/main/java/org/cryptomator/cryptofs/inuse/UseInfo.java new file mode 100644 index 00000000..5ef0bb51 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/inuse/UseInfo.java @@ -0,0 +1,7 @@ +package org.cryptomator.cryptofs.inuse; + +import java.time.Instant; + +public record UseInfo(String owner, Instant lastUpdated) { + +} diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 68bdac27..5b6c7c4a 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -291,7 +291,7 @@ class IsInUseUseInfo { @Test @DisplayName("If the inUse properties have the same owner as the fs, return false") void hasSameOwner() { - var useInfo = new InUseManager.UseInfo("cryptobot3000", Instant.now()); + var useInfo = new UseInfo("cryptobot3000", Instant.now()); var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var result = inUseManager.isInUse(useInfo); @@ -302,7 +302,7 @@ void hasSameOwner() { @Test @DisplayName("If the inUse properties have the lastUpdated timestamp below threshold, return true") void hasDifferentOwnerLastUpdatedBelowTreshold() { - var useInfo = new InUseManager.UseInfo("bob",Instant.now().minus(3, ChronoUnit.MINUTES)); + var useInfo = new UseInfo("bob",Instant.now().minus(3, ChronoUnit.MINUTES)); var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var result = inUseManager.isInUse(useInfo); @@ -313,7 +313,7 @@ void hasDifferentOwnerLastUpdatedBelowTreshold() { @Test @DisplayName("If the inUse properties have the lastUpdated timestamp above threshold, return true") void hasDifferentOwnerLastUpdatedAboveThreshold() { - var useInfo = new InUseManager.UseInfo("bob",Instant.now().minus(20, ChronoUnit.MINUTES)); + var useInfo = new UseInfo("bob",Instant.now().minus(20, ChronoUnit.MINUTES)); var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var result = inUseManager.isInUse(useInfo); @@ -345,7 +345,7 @@ void isInUseChecksUseInfoCache() throws IOException { var ignoredInUseFiles = mock(Cache.class); doReturn(null).when(ignoredInUseFiles).getIfPresent(inUseFilePath); var useInfoCache = mock(Cache.class); - var useInfo = new InUseManager.UseInfo("bob", Instant.now()); + var useInfo = new UseInfo("bob", Instant.now()); doReturn(useInfo).when(useInfoCache).get(eq(inUseFilePath), any()); var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache); From f30e5e02e817d0a70c4b1ee1062ec2620fcb5e42 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 10 Oct 2025 13:44:43 +0200 Subject: [PATCH 55/95] refactor FileIsInUseEvent * replace Properties field with two plain data fields --- .../cryptofs/CryptoFileSystemImpl.java | 18 +++++++++++------- .../cryptofs/event/FileIsInUseEvent.java | 8 +++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index a54343e7..6d5d3ed3 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -22,9 +22,10 @@ import org.cryptomator.cryptofs.dir.DirectoryStreamFilters; import org.cryptomator.cryptofs.event.FileIsInUseEvent; import org.cryptomator.cryptofs.event.FilesystemEvent; -import org.cryptomator.cryptofs.inuse.FileAlreadyInUseException; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; +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 java.io.IOException; @@ -60,12 +61,12 @@ import java.nio.file.attribute.PosixFileAttributes; import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.UserPrincipalLookupService; +import java.time.Instant; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; import java.util.Map; import java.util.Optional; -import java.util.Properties; import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -413,7 +414,7 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti FileChannel ch = null; try { - ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options,attrs); // might throw FileAlreadyExists + ch = openCryptoFiles.getOrCreate(ciphertextFilePath).newFileChannel(options, attrs); // might throw FileAlreadyExists if (options.writable()) { ciphertextPath.persistLongFileName(); stats.incrementAccessesWritten(); @@ -425,9 +426,10 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti return ch; } catch (Exception e) { if (e instanceof FileAlreadyInUseException) { - eventConsumer.accept(new FileIsInUseEvent(cleartextFilePath, ciphertextFilePath, new Properties())); //TODO: properties? + var useInfo = inUseManager.getUseInfo(ciphertextFilePath).orElse(new UseInfo("UNKNOWN", Instant.now())); + eventConsumer.accept(new FileIsInUseEvent(cleartextFilePath, ciphertextFilePath, useInfo.owner(), useInfo.lastUpdated())); } - if(ch != null) { + if (ch != null) { ch.close(); } throw e; @@ -727,8 +729,10 @@ public String toString() { //visible for testing void checkUsage(CryptoPath cleartextPath, CiphertextFilePath ciphertextPath) throws FileAlreadyInUseException { - if (inUseManager.isInUseByOthers(ciphertextPath.getFilePath())) { - eventConsumer.accept(new FileIsInUseEvent(cleartextPath, ciphertextPath.getRawPath(), new Properties())); //TODO: properties?? + var path = ciphertextPath.getFilePath(); + if (inUseManager.isInUseByOthers(path)) { + var useInfo = inUseManager.getUseInfo(path).orElse(new UseInfo("UNKNOWN", Instant.now())); + eventConsumer.accept(new FileIsInUseEvent(cleartextPath, ciphertextPath.getRawPath(), useInfo.owner(), useInfo.lastUpdated())); throw new FileAlreadyInUseException(ciphertextPath.getRawPath()); } } diff --git a/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java b/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java index bead9204..9d81fa69 100644 --- a/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java +++ b/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java @@ -1,13 +1,15 @@ package org.cryptomator.cryptofs.event; +import org.cryptomator.cryptofs.inuse.UseInfo; + import java.nio.file.Path; import java.time.Instant; import java.util.Properties; -public record FileIsInUseEvent(Instant timestamp, Path cleartext, Path ciphertext, Properties moreInfo) implements FilesystemEvent { +public record FileIsInUseEvent(Instant timestamp, Path cleartext, Path ciphertext, String owner, Instant lastUpdated) implements FilesystemEvent { - public FileIsInUseEvent(Path cleartext, Path ciphertext, Properties moreInfo) { - this(Instant.now(), cleartext, ciphertext, moreInfo); + public FileIsInUseEvent(Path cleartext, Path ciphertext, String owner, Instant lastUpdated) { + this(Instant.now(), cleartext, ciphertext, owner, lastUpdated); } @Override From 3296a91bd2c63ccd045845de1e48b2ed38fe42cd Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 10 Oct 2025 16:34:22 +0200 Subject: [PATCH 56/95] add method to event which allows to ignore the use-file of the event contained path --- .../cryptomator/cryptofs/CryptoFileSystemImpl.java | 4 ++-- .../cryptofs/event/FileIsInUseEvent.java | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 6d5d3ed3..7cfc3e30 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -427,7 +427,7 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti } catch (Exception e) { if (e instanceof FileAlreadyInUseException) { var useInfo = inUseManager.getUseInfo(ciphertextFilePath).orElse(new UseInfo("UNKNOWN", Instant.now())); - eventConsumer.accept(new FileIsInUseEvent(cleartextFilePath, ciphertextFilePath, useInfo.owner(), useInfo.lastUpdated())); + eventConsumer.accept(new FileIsInUseEvent(cleartextFilePath, ciphertextFilePath, useInfo.owner(), useInfo.lastUpdated(), () -> inUseManager.ignoreInUse(ciphertextFilePath))); } if (ch != null) { ch.close(); @@ -732,7 +732,7 @@ void checkUsage(CryptoPath cleartextPath, CiphertextFilePath ciphertextPath) thr var path = ciphertextPath.getFilePath(); if (inUseManager.isInUseByOthers(path)) { var useInfo = inUseManager.getUseInfo(path).orElse(new UseInfo("UNKNOWN", Instant.now())); - eventConsumer.accept(new FileIsInUseEvent(cleartextPath, ciphertextPath.getRawPath(), useInfo.owner(), useInfo.lastUpdated())); + eventConsumer.accept(new FileIsInUseEvent(cleartextPath, ciphertextPath.getRawPath(), useInfo.owner(), useInfo.lastUpdated(), () -> inUseManager.ignoreInUse(path))); throw new FileAlreadyInUseException(ciphertextPath.getRawPath()); } } diff --git a/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java b/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java index 9d81fa69..66b1af0d 100644 --- a/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java +++ b/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java @@ -1,19 +1,21 @@ package org.cryptomator.cryptofs.event; -import org.cryptomator.cryptofs.inuse.UseInfo; - import java.nio.file.Path; import java.time.Instant; -import java.util.Properties; -public record FileIsInUseEvent(Instant timestamp, Path cleartext, Path ciphertext, String owner, Instant lastUpdated) implements FilesystemEvent { +public record FileIsInUseEvent(Instant timestamp, Path cleartext, Path ciphertext, String owner, Instant lastUpdated, Runnable ignoreMethod) implements FilesystemEvent { - public FileIsInUseEvent(Path cleartext, Path ciphertext, String owner, Instant lastUpdated) { - this(Instant.now(), cleartext, ciphertext, owner, lastUpdated); + public FileIsInUseEvent(Path cleartext, Path ciphertext, String owner, Instant lastUpdated, Runnable ignoreMethod) { + this(Instant.now(), cleartext, ciphertext, owner, lastUpdated, ignoreMethod); } @Override public Instant getTimestamp() { return timestamp; } + + public void ignoreInUse() { + ignoreMethod.run(); + } + } From 7c851678a69b261109a93a513aa58b02adf24aa0 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 10 Oct 2025 16:40:18 +0200 Subject: [PATCH 57/95] remove not needed assertion in test --- .../java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index 43073e0c..94fb6b78 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -83,7 +83,6 @@ public void testFileSteal() throws IOException { var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); Assertions.assertTrue(createEvent.isPresent()); createEvent.ifPresent(e -> { - Assertions.assertEquals(1, e.count()); Assertions.assertTrue(filePath.endsWith((Path) e.context())); }); } From 4d5204a08bbcb742c9bc5e5a941a1a74bdd74259 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 14 Oct 2025 16:53:49 +0200 Subject: [PATCH 58/95] add refresh logic --- .../cryptofs/inuse/RealInUseManager.java | 19 ++++++++++++++----- .../cryptofs/inuse/RealUseToken.java | 12 ++++++++---- .../cryptofs/inuse/RealInUseManagerTest.java | 14 +++++++------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 77e50e09..3b230522 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -25,7 +25,8 @@ import java.util.Optional; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; /** @@ -40,9 +41,10 @@ public class RealInUseManager implements InUseManager { private static final Logger LOG = LoggerFactory.getLogger(RealInUseManager.class); private static final int REFRESH_DELAY_MINUTES = 5; - private final ConcurrentMap useTokens; + private final ConcurrentHashMap useTokens; private final Cache useInfoCache; private final Cache ignoredInUseFiles; + private final ScheduledExecutorService tokenRefresher; private final String owner; private final Cryptor cryptor; @@ -55,7 +57,14 @@ public RealInUseManager(@NonNull String owner, Cryptor cryptor) { .maximumSize(100) // .build(); this.useInfoCache = Caffeine.newBuilder() // - .expireAfterWrite(5, TimeUnit.SECONDS).maximumSize(1000).build(); + .expireAfterWrite(5, TimeUnit.SECONDS) // + .maximumSize(1000) // + .build(); + this.tokenRefresher = Executors.newSingleThreadScheduledExecutor(); + tokenRefresher.scheduleWithFixedDelay(() -> useTokens.forEachValue(10L, RealUseToken::refresh), // + REFRESH_DELAY_MINUTES, // + REFRESH_DELAY_MINUTES, // + TimeUnit.MINUTES); } @@ -210,13 +219,13 @@ static Path computeInUseFilePath(Path p) { return p.resolveSibling(fileName + Constants.INUSE_FILE_SUFFIX); } - //for testing - RealInUseManager(String owner, Cryptor cryptor, ConcurrentMap useTokens, Cache ignoredInUseFiles, Cache useInfoCache) { + RealInUseManager(String owner, Cryptor cryptor, ConcurrentHashMap useTokens, Cache ignoredInUseFiles, Cache useInfoCache, ScheduledExecutorService tokenRefresher) { this.owner = owner; this.cryptor = cryptor; this.useTokens = useTokens; this.ignoredInUseFiles = ignoredInUseFiles; this.useInfoCache = useInfoCache; + this.tokenRefresher = tokenRefresher; } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 65994fab..4138f9a9 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -122,16 +122,20 @@ private void createInUseFile() throws IOException { } } - private void updateInUseFile() throws IOException { + void refresh() { try { - writeInUseFile(filePath, Set.of(StandardOpenOption.WRITE)); + fileCreationSync.lock(); + if (!(channel == null || closed)) { + writeInUseFile(filePath, Set.of(StandardOpenOption.WRITE)); + } } catch (IOException e) { LOG.warn("Failed to update in-use file {}.", filePath, e); - throw e; + } finally { + fileCreationSync.unlock(); } } - //TODO: refresh logic? + //TODO: testtestest void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { var ch = Files.newByteChannel(inUseFilePath, openOptions); this.channel = encWrapper.wrapWithEncryption(ch, cryptor); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 5b6c7c4a..db7609f5 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -55,7 +55,7 @@ public void testUseByOthersWithExistingToken() throws IOException { preparedMap.put(inUseFilePath, mock(RealUseToken.class)); var filesMarkedForStealing = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing, useInfoCache); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing, useInfoCache, null); var inUseSpy = spy(inUseManager); var result = inUseSpy.isInUseByOthers(ciphertextPath); @@ -135,7 +135,7 @@ public void testCreateExistingValid() throws IOException { var ignoredInUseFiles = mock(Cache.class); doNothing().when(ignoredInUseFiles).invalidate(inUseFilePath); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, null); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -157,7 +157,7 @@ public void testCreateExistingInvalid() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, null); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -178,7 +178,7 @@ public void testCreateNotExisting() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, null); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -199,7 +199,7 @@ public void testCreateFailedRead() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, null); var inUseSpy = spy(inUseManager); doReturn(true).when(inUseSpy).isInUse(inUseFilePath); @@ -330,7 +330,7 @@ void isInUseChecksIgnoredCache() throws IOException { var ignoredInUseFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); doReturn(Boolean.TRUE).when(ignoredInUseFiles).getIfPresent(inUseFilePath); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, null); var result = inUseManager.isInUse(inUseFilePath); @@ -347,7 +347,7 @@ void isInUseChecksUseInfoCache() throws IOException { var useInfoCache = mock(Cache.class); var useInfo = new UseInfo("bob", Instant.now()); doReturn(useInfo).when(useInfoCache).get(eq(inUseFilePath), any()); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, null); var result = inUseManager.isInUse(inUseFilePath); From 57d3649cd480078ad2887133a5396ed8fbdb63db Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 14 Oct 2025 16:54:17 +0200 Subject: [PATCH 59/95] resolve todo --- src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 4138f9a9..08cbb5f3 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -206,6 +206,7 @@ public void close() { } catch (IOException e) { //ignore //TODO: LOG + LOG.info("Failed to delete inUse File {}. Must be deleted manually.", path); } } return null; From 3888eba736ca743a01626643a92e5112316f10fe Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 14 Oct 2025 17:27:30 +0200 Subject: [PATCH 60/95] remove usage of jspecify --- src/main/java/module-info.java | 1 - .../java/org/cryptomator/cryptofs/inuse/RealInUseManager.java | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 706fd7a0..2e81cc8d 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -21,7 +21,6 @@ requires static javax.inject; requires jakarta.inject; requires java.compiler; - requires org.jspecify; exports org.cryptomator.cryptofs; exports org.cryptomator.cryptofs.event; diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 3b230522..bf436052 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -6,7 +6,6 @@ import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptolib.api.Cryptor; -import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,7 +47,7 @@ public class RealInUseManager implements InUseManager { private final String owner; private final Cryptor cryptor; - public RealInUseManager(@NonNull String owner, Cryptor cryptor) { + public RealInUseManager(String owner, Cryptor cryptor) { this.owner = owner; this.cryptor = cryptor; this.useTokens = new ConcurrentHashMap<>(); From 83b596ebe701aa1ee078a655365ed6df565fc875 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 14 Oct 2025 17:27:52 +0200 Subject: [PATCH 61/95] doc doc --- src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index 0f577757..074070c6 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -35,7 +35,7 @@ default Optional getUseInfo(Path ciphertextPath) { /** - * Marks the given ciphertextpath as used. + * Marks the given ciphertextpath as used. * * @param ciphertextPath Path to the ciphertext file which should be marked as used. * @return A {@link UseToken} representing a use-ship for this file From 64d3e38d4c5d4743a39d1ed3d3056574c0ef10a7 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 14 Oct 2025 17:29:09 +0200 Subject: [PATCH 62/95] fix validation method --- .../org/cryptomator/cryptofs/inuse/RealInUseManager.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index bf436052..54a33748 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -128,13 +128,15 @@ Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgument //TODO: test UseInfo validate(Properties content) throws IllegalArgumentException { - if (!content.containsKey(UseToken.OWNER_KEY)) { + var owner = (String) content.get(UseToken.OWNER_KEY); + if (owner == null || owner.isBlank()) { throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.OWNER_KEY)); } - if (!content.containsKey(UseToken.LASTUPDATED_KEY)) { + + var stringTime = (String) content.get(UseToken.LASTUPDATED_KEY); + if (stringTime == null) { throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.LASTUPDATED_KEY)); } - var stringTime = (String) content.get(UseToken.LASTUPDATED_KEY); try { var lastUpdated = Instant.parse(stringTime); return new UseInfo(owner, lastUpdated); From 3a64f099d133d128eed4330eb91dc5a7041396a1 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 14 Oct 2025 18:53:12 +0200 Subject: [PATCH 63/95] don't mask actual exception with close() failure --- .../java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 7cfc3e30..c6930855 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -430,7 +430,11 @@ private FileChannel newFileChannelFromFile(CryptoPath cleartextFilePath, Effecti eventConsumer.accept(new FileIsInUseEvent(cleartextFilePath, ciphertextFilePath, useInfo.owner(), useInfo.lastUpdated(), () -> inUseManager.ignoreInUse(ciphertextFilePath))); } if (ch != null) { - ch.close(); + try { + ch.close(); + } catch (IOException closeEx) { + e.addSuppressed(closeEx); + } } throw e; } From ee96d60fc9e1f46c9915997d656255624d16ff6f Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 14 Oct 2025 18:56:33 +0200 Subject: [PATCH 64/95] fix doc --- .../org/cryptomator/cryptofs/inuse/RealUseToken.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 08cbb5f3..8005f622 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -30,12 +30,11 @@ * Class to represent a file is "in use" by this filesystem. *

    * The actual persistence of the "in use"-state with a file is delayed by {@value Constants#INUSE_DELAY_MILLIS} milliseconds. - * The content of the in-use-file is a JSON containing - *

  • - *
      owner - name of the filesystem owner
    - *
      since - UTC-timestamp encoded as epoch seconds from the standard Java epoch of 1970-01-01T00:00:00Z
    - *
  • - * The JSON data is encrypted with the vault masterkey. + * The in-use file contains Java Properties, encrypted with the vault masterkey: + *
      + *
    • owner — name of the filesystem owner
    • + *
    • lastUpdated — UTC timestamp in ISO-8601
    • + *
    * If the token is closed before the persistence started, the persistence is not performed. */ public final class RealUseToken implements UseToken { From 7dd7540d2dc74d3b63b55f8df93ccd3fa65997d4 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 14 Oct 2025 19:13:16 +0200 Subject: [PATCH 65/95] fix unclosed inUse file channels --- .../cryptofs/inuse/RealUseToken.java | 62 ++++++------------- 1 file changed, 20 insertions(+), 42 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 8005f622..a3538eed 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -74,10 +74,10 @@ public static RealUseToken createInvalid(Path p, ConcurrentMap this::stealInUseFile; - case CREATE -> this::createInUseFile; - case NONE -> () -> {}; + Set openOptions = switch (m) { + case STEAL -> Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + case CREATE -> Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + case NONE -> Set.of(); }; if (m == ActivationType.NONE) { @@ -85,47 +85,34 @@ public static RealUseToken createInvalid(Path p, ConcurrentMap { - try { - fileCreationSync.lock(); - if (closed) { - return; - } - //Do critical stuff - method.execute(); - } catch (IOException e) { - close(); - } finally { - fileCreationSync.unlock(); - } - }, CompletableFuture.delayedExecutor(Constants.INUSE_DELAY_MILLIS, TimeUnit.MILLISECONDS, Executors.newVirtualThreadPerTaskExecutor())); + this.creationTask = CompletableFuture.runAsync(() -> createInUseFile(openOptions), CompletableFuture.delayedExecutor(Constants.INUSE_DELAY_MILLIS, TimeUnit.MILLISECONDS, Executors.newVirtualThreadPerTaskExecutor())); } } - private void stealInUseFile() throws IOException { + private void createInUseFile(Set openOptions) { try { - writeInUseFile(filePath, Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)); + fileCreationSync.lock(); + if (closed) { + return; + } + var ch = Files.newByteChannel(filePath, openOptions); + this.channel = encWrapper.wrapWithEncryption(ch, cryptor); + writeInUseFile(); } catch (IOException e) { - LOG.warn("Failed to steal in-use file {}.", filePath, e); - throw e; + LOG.warn("Failed to write in-use file {} with open options {}.", filePath, openOptions, e); + close(); + } finally { + fileCreationSync.unlock(); } - } - private void createInUseFile() throws IOException { - try { - writeInUseFile(filePath, Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)); - } catch (IOException e) { - LOG.warn("Failed to create in-use file {}.", filePath, e); - throw e; - } } void refresh() { try { fileCreationSync.lock(); if (!(channel == null || closed)) { - writeInUseFile(filePath, Set.of(StandardOpenOption.WRITE)); + writeInUseFile(); } } catch (IOException e) { LOG.warn("Failed to update in-use file {}.", filePath, e); @@ -134,16 +121,13 @@ void refresh() { } } - //TODO: testtestest - void writeInUseFile(Path inUseFilePath, Set openOptions) throws IOException { - var ch = Files.newByteChannel(inUseFilePath, openOptions); - this.channel = encWrapper.wrapWithEncryption(ch, cryptor); + int writeInUseFile() throws IOException { var rawInfo = new ByteArrayOutputStream(Constants.INUSE_CLEARTEXT_SIZE); var prop = new Properties(); prop.put(UseToken.OWNER_KEY, owner); prop.put(UseToken.LASTUPDATED_KEY, Instant.now().toString()); prop.store(rawInfo, null); - channel.write(ByteBuffer.wrap(rawInfo.toByteArray())); + return channel.write(ByteBuffer.wrap(rawInfo.toByteArray())); } @Override @@ -221,12 +205,6 @@ enum ActivationType { NONE; } - @FunctionalInterface - interface FileOperation { - - void execute() throws IOException; - } - interface EncryptionDecorator { WritableByteChannel wrapWithEncryption(ByteChannel ch, Cryptor c); From b6f0e4f6a6e34a8684627792123589ba9bb00f99 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 15 Oct 2025 16:25:17 +0200 Subject: [PATCH 66/95] doc doc doc --- .../cryptomator/cryptofs/inuse/InUseManager.java | 10 +++++----- .../cryptofs/inuse/RealInUseManager.java | 8 ++++---- .../cryptomator/cryptofs/inuse/RealUseToken.java | 7 +------ .../cryptomator/cryptofs/inuse/package-info.java | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 15 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/inuse/package-info.java diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index 074070c6..236a69f8 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -4,12 +4,12 @@ import java.util.Optional; /** - * A file is considered in use, if: + * The InUseManager offers methods to *
      - *
    • an in-use-file for the given file exists
    • - *
    • that in-use-file belongs to the running crypto filesystem
    • - *
    • the last update time is at most X minutes ago
    • - *
    . + *
  • determine if a file is in-use by a different filesystem
  • + *
  • create an in-use-file and claim ownership of it
  • + *
  • ignore the in-use-file for a ciphertext path
  • + * */ public interface InUseManager { diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 54a33748..0859f0e0 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -29,11 +29,11 @@ import java.util.concurrent.TimeUnit; /** - * Management object for the in-use-state of encrypted files. + * Real implementation of {@link InUseManager}. *

    - * You can just check, if a file is in use with {@link #isInUseByOthers(Path)} or try to mark a file as in-use by this crypto filesystem with {@link #use(Path)} - *

    - * The persistence file of a token has the {@value Constants#INUSE_FILE_SUFFIX} file extension. + * All {@value REFRESH_DELAY_MINUTES} minutes all open UseTokens (aka in-use-files) are rewritten with "lastUpdated" set to the current time. + * To reduce reads from disk, this class implements a short-lived (5s) cache of the in-use-files. + * If a file is ignored via {@link #ignoreInUse(Path)}, the ignore status is kept for only 2 minutes. */ public class RealInUseManager implements InUseManager { diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index a3538eed..7a7462bf 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -30,12 +30,7 @@ * Class to represent a file is "in use" by this filesystem. *

    * The actual persistence of the "in use"-state with a file is delayed by {@value Constants#INUSE_DELAY_MILLIS} milliseconds. - * The in-use file contains Java Properties, encrypted with the vault masterkey: - *

      - *
    • owner — name of the filesystem owner
    • - *
    • lastUpdated — UTC timestamp in ISO-8601
    • - *
    - * If the token is closed before the persistence started, the persistence is not performed. + * If the token is closed before it is persisted with a file, writing it to disk is skipped. */ public final class RealUseToken implements UseToken { diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/package-info.java b/src/main/java/org/cryptomator/cryptofs/inuse/package-info.java new file mode 100644 index 00000000..262e11b6 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/inuse/package-info.java @@ -0,0 +1,16 @@ +/** + * Package containing all necessary components for the in-use feature. + *

    + * A file is considered in use, if a valid in-use-file exists. + *

    + * A valid in-use file has the same filename as the file, which should be marked in use. + * Its file extension is {@value org.cryptomator.cryptofs.common.Constants#INUSE_FILE_SUFFIX}. + * It contains Java Properties, encrypted with the vault masterkey: + *

      + *
    • owner — name of the filesystem owner
    • + *
    • lastUpdated — UTC timestamp in ISO-8601
    • + *
    + *

    + * To check if a file is in-use or to mark it as in-use, see {@link org.cryptomator.cryptofs.inuse.InUseManager} and its implementation {@link org.cryptomator.cryptofs.inuse.RealInUseManager} + */ +package org.cryptomator.cryptofs.inuse; \ No newline at end of file From 463d8dce09fb0fb1a588e6e7f255782f71e973b9 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 15 Oct 2025 16:51:52 +0200 Subject: [PATCH 67/95] prevent unwanted integer cast --- .../java/org/cryptomator/cryptofs/inuse/RealInUseManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 0859f0e0..cf898ec7 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -151,7 +151,7 @@ boolean isInUse(UseInfo useInfo) { } var timeSinceLastUpdate = Duration.between(useInfo.lastUpdated(), Instant.now()); - var threshold = Duration.of(2 * REFRESH_DELAY_MINUTES, ChronoUnit.MINUTES); + var threshold = Duration.of(2L * REFRESH_DELAY_MINUTES, ChronoUnit.MINUTES); return timeSinceLastUpdate.compareTo(threshold) < 0; } From fc70e09bd6e12f3a4761501e7db820757a78160b Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 15 Oct 2025 17:00:39 +0200 Subject: [PATCH 68/95] clean up --- .../cryptomator/cryptofs/DirectoryIdBackup.java | 3 --- .../cryptofs/inuse/RealInUseManager.java | 17 +++++++---------- .../cryptofs/inuse/RealUseToken.java | 5 ++--- .../cryptofs/inuse/RealInUseManagerTest.java | 4 ++-- .../cryptofs/inuse/RealUseTokenTest.java | 12 ++++++------ 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java b/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java index 106acdb7..b2e7b54a 100644 --- a/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java +++ b/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java @@ -5,12 +5,9 @@ import org.cryptomator.cryptofs.common.EncryptedChannels; import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; -import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; -import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; import java.io.IOException; import java.nio.ByteBuffer; -import java.nio.channels.ByteChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index cf898ec7..53b3ae3c 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -76,7 +76,7 @@ public boolean isInUseByOthers(Path ciphertextPath) { try { return isInUse(inUseFilePath); - } catch (IllegalArgumentException | IOException e) { + } catch (IllegalArgumentException | IOException _) { return false; } } @@ -128,8 +128,8 @@ Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgument //TODO: test UseInfo validate(Properties content) throws IllegalArgumentException { - var owner = (String) content.get(UseToken.OWNER_KEY); - if (owner == null || owner.isBlank()) { + var ownerFromFile = (String) content.get(UseToken.OWNER_KEY); + if (ownerFromFile == null || ownerFromFile.isBlank()) { throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.OWNER_KEY)); } @@ -139,7 +139,7 @@ UseInfo validate(Properties content) throws IllegalArgumentException { } try { var lastUpdated = Instant.parse(stringTime); - return new UseInfo(owner, lastUpdated); + return new UseInfo(ownerFromFile, lastUpdated); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid in-use-file. Unable to parse content %s of key %s as UTC timestamp.".formatted(stringTime, UseToken.LASTUPDATED_KEY), e); } @@ -177,7 +177,7 @@ public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { if (e.getCause() instanceof FileAlreadyInUseException inUseExc) { throw inUseExc; } else { - //any other IOException. Already logged. + LOG.warn("Failed to read in-use file {}. Ignoring it.", inUseFilePath, e); return UseToken.CLOSED_TOKEN; } } @@ -190,17 +190,14 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { } ignoredInUseFiles.invalidate(inUseFilePath); return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); - } catch (FileAlreadyInUseException e) { - throw new UncheckedIOException(e); //wrapped due to Map::compute method } catch (NoSuchFileException e) { LOG.debug("No in-use-file {} found. Creating it.", inUseFilePath, e); return RealUseToken.createWithNewFile(inUseFilePath, owner, cryptor, useTokens); + } catch (IOException e) { + throw new UncheckedIOException(e); //wrapped due to Map::compute method } catch (IllegalArgumentException e) { LOG.info("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); - } catch (IOException e) { - LOG.warn("Failed to read in-use file {}. Ignoring it.", inUseFilePath, e); - throw new UncheckedIOException(e); //wrapped due to Map::compute method } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 7a7462bf..2670d3e9 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -140,7 +140,7 @@ void moveToInternal(Path newFilePath) { if (closed) { return; } - useTokens.compute(newFilePath, (p, t) -> { + useTokens.compute(newFilePath, (_, _) -> { try { if (channel != null) { Files.move(filePath, newFilePath, StandardCopyOption.REPLACE_EXISTING); @@ -176,14 +176,13 @@ public void close() { } closed = true; creationTask.cancel(false); - useTokens.compute(filePath, (path, token) -> { + useTokens.compute(filePath, (path, _) -> { if (channel != null) { try { channel.close(); Files.deleteIfExists(filePath); } catch (IOException e) { //ignore - //TODO: LOG LOG.info("Failed to delete inUse File {}. Must be deleted manually.", path); } } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index db7609f5..1e507ad3 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -104,7 +104,7 @@ public void testUsePutsPathInMap() throws FileAlreadyInUseException { @Test @DisplayName("\"use\" method rethrow FileAlreadyInUseException") - public void testUseThrows() throws FileAlreadyInUseException { + public void testUseThrows() { var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); var inUseException = new FileAlreadyInUseException(inUseFilePath); @@ -117,7 +117,7 @@ public void testUseThrows() throws FileAlreadyInUseException { @Test @DisplayName("\"use\" method returns CLOSED_TOKEN on IOException") - public void testUseClosedToken() throws FileAlreadyInUseException { + public void testUseClosedToken() { var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var inUseSpy = spy(inUseManager); var someIOException = new IOException("it's over 9000!"); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index 94fb6b78..a9669a84 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -35,14 +35,14 @@ public class RealUseTokenTest { Path tmpDir; private WatchService watchService; - private final static Duration FILE_OPERATION_DELAY = Duration.ofMillis(Constants.INUSE_DELAY_MILLIS - 100); - private final static Duration FILE_OPERATION_MAX = FILE_OPERATION_DELAY.plusMillis(3000); + private static final Duration FILE_OPERATION_DELAY = Duration.ofMillis(Constants.INUSE_DELAY_MILLIS - 100); + private static final Duration FILE_OPERATION_MAX = FILE_OPERATION_DELAY.plusMillis(3000); @BeforeEach public void beforeEach() throws IOException { cryptor = mock(Cryptor.class); encWrapper = mock(RealUseToken.EncryptionDecorator.class); - useTokens = new ConcurrentHashMap<>();//Mockito.mock(ConcurrentMap.class); + useTokens = new ConcurrentHashMap<>(); watchService = tmpDir.getFileSystem().newWatchService(); doAnswer(invocation -> invocation.getArgument(0)) //just return the real file channel @@ -53,14 +53,14 @@ public void beforeEach() throws IOException { public void afterEach() { try { watchService.close(); - } catch (IOException e) { + } catch (IOException _) { //no-op } } @Test @DisplayName("After 5 seconds of token creation, a new file is created") - public void testFileCreation() throws IOException { + public void testFileCreation() { var filePath = tmpDir.resolve("inUse.file"); try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); @@ -168,7 +168,7 @@ public void testMoveToBefore() throws IOException { @Test @DisplayName("Moving a token after file creation") - public void testMoveToAfter() throws IOException { + public void testMoveToAfter() { var filePath = tmpDir.resolve("inUse.file"); var targetPath = tmpDir.resolve("inUse2.file"); From 096079218afc9b7d82eca05450383f05bdfca21b Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 16 Oct 2025 12:26:36 +0200 Subject: [PATCH 69/95] always open a new file channel on refresh --- .../cryptomator/cryptofs/inuse/RealUseToken.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 2670d3e9..ef7a34f9 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -95,7 +95,7 @@ private void createInUseFile(Set openOptions) { this.channel = encWrapper.wrapWithEncryption(ch, cryptor); writeInUseFile(); } catch (IOException e) { - LOG.warn("Failed to write in-use file {} with open options {}.", filePath, openOptions, e); + LOG.debug("Failed to write in-use file {} with open options {}.", filePath, openOptions, e); close(); } finally { fileCreationSync.unlock(); @@ -104,15 +104,13 @@ private void createInUseFile(Set openOptions) { } void refresh() { + var oldChannel = channel; + createInUseFile(Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)); + try { - fileCreationSync.lock(); - if (!(channel == null || closed)) { - writeInUseFile(); - } + oldChannel.close(); } catch (IOException e) { - LOG.warn("Failed to update in-use file {}.", filePath, e); - } finally { - fileCreationSync.unlock(); + LOG.warn("Failed to close stale channel to in-use-file {}", filePath, e); } } From 943503d258a681e3f57eb32a303ddfe1a5242630 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 16 Oct 2025 12:27:02 +0200 Subject: [PATCH 70/95] adjust log levels --- .../java/org/cryptomator/cryptofs/inuse/RealUseToken.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index ef7a34f9..9582db73 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -151,7 +151,7 @@ void moveToInternal(Path newFilePath) { useTokens.remove(filePath); this.filePath = newFilePath; } catch (UncheckedIOException e) { - LOG.warn("Failed to move in-use file {} to {}.", filePath, newFilePath, e.getCause()); + LOG.debug("Failed to move in-use file {} to {}.", filePath, newFilePath, e.getCause()); close(); //To prevent invalid states } finally { fileCreationSync.unlock(); @@ -181,7 +181,7 @@ public void close() { Files.deleteIfExists(filePath); } catch (IOException e) { //ignore - LOG.info("Failed to delete inUse File {}. Must be deleted manually.", path); + LOG.warn("Failed to delete inUse File {}. Must be deleted manually.", path); } } return null; From e5fb49eec5f37928f1694a44cd0746caebc536ad Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 16 Oct 2025 12:31:46 +0200 Subject: [PATCH 71/95] Remove unused code and simplify code --- .../cryptofs/inuse/RealUseToken.java | 36 +++++-------------- .../cryptofs/inuse/RealUseTokenTest.java | 30 +++++----------- 2 files changed, 16 insertions(+), 50 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 9582db73..3aaa7063 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -35,15 +35,11 @@ public final class RealUseToken implements UseToken { public static RealUseToken createWithNewFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { - return new RealUseToken(p, owner, cryptor, useTokens, ActivationType.CREATE); + return new RealUseToken(p, owner, cryptor, useTokens, StandardOpenOption.CREATE_NEW); } public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { - return new RealUseToken(p, owner, cryptor, useTokens, ActivationType.STEAL); - } - - public static RealUseToken createInvalid(Path p, ConcurrentMap useTokens) { - return new RealUseToken(p, "unused", null, useTokens, ActivationType.NONE); + return new RealUseToken(p, owner, cryptor, useTokens, StandardOpenOption.TRUNCATE_EXISTING); } private static final Logger LOG = LoggerFactory.getLogger(RealUseToken.class); @@ -59,29 +55,19 @@ public static RealUseToken createInvalid(Path p, ConcurrentMap useTokens, ActivationType m) { - this(filePath, owner, cryptor, useTokens, m, EncryptedChannels::wrapEncryptionAround); + RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, OpenOption openMode) { + this(filePath, owner, cryptor, useTokens, openMode, EncryptedChannels::wrapEncryptionAround); } - RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, ActivationType m, EncryptionDecorator encWrapper) { + RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, OpenOption openMode, EncryptionDecorator encWrapper) { this.owner = owner; this.filePath = filePath; this.cryptor = cryptor; this.useTokens = useTokens; this.encWrapper = encWrapper; - Set openOptions = switch (m) { - case STEAL -> Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); - case CREATE -> Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); - case NONE -> Set.of(); - }; - - if (m == ActivationType.NONE) { - this.closed = true; - this.creationTask = CompletableFuture.completedFuture(null); - } else { - this.closed = false; - this.creationTask = CompletableFuture.runAsync(() -> createInUseFile(openOptions), CompletableFuture.delayedExecutor(Constants.INUSE_DELAY_MILLIS, TimeUnit.MILLISECONDS, Executors.newVirtualThreadPerTaskExecutor())); - } + this.closed = false; + var openOptions = Set.of(StandardOpenOption.WRITE, openMode); + this.creationTask = CompletableFuture.runAsync(() -> createInUseFile(openOptions), CompletableFuture.delayedExecutor(Constants.INUSE_DELAY_MILLIS, TimeUnit.MILLISECONDS, Executors.newVirtualThreadPerTaskExecutor())); } @@ -191,12 +177,6 @@ public void close() { } } - enum ActivationType { - CREATE, - STEAL, - NONE; - } - interface EncryptionDecorator { WritableByteChannel wrapWithEncryption(ByteChannel ch, Cryptor c); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index a9669a84..0b1f71b5 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -15,6 +15,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchService; import java.time.Duration; @@ -62,7 +63,7 @@ public void afterEach() { @DisplayName("After 5 seconds of token creation, a new file is created") public void testFileCreation() { var filePath = tmpDir.resolve("inUse.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); Assertions.assertTrue(Files.exists(filePath)); } @@ -77,7 +78,7 @@ public void testFileSteal() throws IOException { var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); var fileTime = Files.getLastModifiedTime(filePath); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.STEAL, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); var events = watchKey.pollEvents(); var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); @@ -90,28 +91,13 @@ public void testFileSteal() throws IOException { Assertions.assertNull(useTokens.get(filePath)); } - @Test - @DisplayName("Invalid token creation does nothing") - public void testInvalid() throws IOException { - var filePath = tmpDir.resolve("inUse.file"); - var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); - - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.NONE, encWrapper)) { - Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); - Assertions.assertTrue(Files.notExists(filePath)); - Assertions.assertTrue(token.isClosed()); - Assertions.assertNull(useTokens.get(filePath)); - MatcherAssert.assertThat(watchKey.pollEvents(), Matchers.empty()); - } - } - @Test @DisplayName("After 5 seconds of token creation, failed steal closes the token ") public void testFileStealFails() throws IOException { var filePath = tmpDir.resolve("inUse.file"); //file does not exist var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.STEAL, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(token::isClosed); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); @@ -126,7 +112,7 @@ public void testTokenCloseBeforeFileOperation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { Assertions.assertTrue(Files.notExists(filePath)); } Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); @@ -142,7 +128,7 @@ public void testMoveToBefore() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { token.moveToInternal(targetPath); //no file operation after move @@ -172,7 +158,7 @@ public void testMoveToAfter() { var filePath = tmpDir.resolve("inUse.file"); var targetPath = tmpDir.resolve("inUse2.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); token.moveToInternal(targetPath); @@ -195,7 +181,7 @@ public void testMoveToClosed() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, RealUseToken.ActivationType.CREATE, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { token.close(); Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); From 5a89b48216332b75e2a37f5101c55184e63f330a Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 16 Oct 2025 17:16:14 +0200 Subject: [PATCH 72/95] reduce log level --- .../org/cryptomator/cryptofs/inuse/RealInUseManager.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 53b3ae3c..2d8ebe57 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -23,6 +23,7 @@ import java.time.temporal.ChronoUnit; import java.util.Optional; import java.util.Properties; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -191,12 +192,12 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { ignoredInUseFiles.invalidate(inUseFilePath); return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); } catch (NoSuchFileException e) { - LOG.debug("No in-use-file {} found. Creating it.", inUseFilePath, e); + LOG.trace("No in-use-file {} found. Creating it.", inUseFilePath, e); return RealUseToken.createWithNewFile(inUseFilePath, owner, cryptor, useTokens); } catch (IOException e) { throw new UncheckedIOException(e); //wrapped due to Map::compute method } catch (IllegalArgumentException e) { - LOG.info("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); + LOG.debug("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); } } From 8fb283bdb9cefbfb53284cf0b72c9ed46c0a61f8 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Thu, 16 Oct 2025 17:38:51 +0200 Subject: [PATCH 73/95] reset filechannel after writing to file --- .../cryptofs/inuse/RealUseToken.java | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 3aaa7063..5ff741ef 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -11,6 +11,7 @@ import java.io.UncheckedIOException; import java.nio.ByteBuffer; import java.nio.channels.ByteChannel; +import java.nio.channels.SeekableByteChannel; import java.nio.channels.WritableByteChannel; import java.nio.file.Files; import java.nio.file.OpenOption; @@ -52,7 +53,7 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor private final ReentrantReadWriteLock.WriteLock fileCreationSync = new ReentrantReadWriteLock().writeLock(); private volatile Path filePath; - private volatile WritableByteChannel channel; + private volatile SeekableByteChannel channel; private volatile boolean closed; RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, OpenOption openMode) { @@ -77,8 +78,7 @@ private void createInUseFile(Set openOptions) { if (closed) { return; } - var ch = Files.newByteChannel(filePath, openOptions); - this.channel = encWrapper.wrapWithEncryption(ch, cryptor); + this.channel = Files.newByteChannel(filePath, openOptions); writeInUseFile(); } catch (IOException e) { LOG.debug("Failed to write in-use file {} with open options {}.", filePath, openOptions, e); @@ -90,23 +90,30 @@ private void createInUseFile(Set openOptions) { } void refresh() { - var oldChannel = channel; - createInUseFile(Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)); - try { - oldChannel.close(); + fileCreationSync.lock(); + if (closed || channel == null) { + return; + } + writeInUseFile(); + channel.position(0); } catch (IOException e) { - LOG.warn("Failed to close stale channel to in-use-file {}", filePath, e); + LOG.debug("Failed to refresh in-use file {}.", filePath, e); + } finally { + fileCreationSync.unlock(); } } int writeInUseFile() throws IOException { - var rawInfo = new ByteArrayOutputStream(Constants.INUSE_CLEARTEXT_SIZE); - var prop = new Properties(); - prop.put(UseToken.OWNER_KEY, owner); - prop.put(UseToken.LASTUPDATED_KEY, Instant.now().toString()); - prop.store(rawInfo, null); - return channel.write(ByteBuffer.wrap(rawInfo.toByteArray())); + try (var nonClosingWrapper = new NonClosingByteChannel(channel); // + var encChannel = encWrapper.wrapWithEncryption(nonClosingWrapper, cryptor)) { + var rawInfo = new ByteArrayOutputStream(Constants.INUSE_CLEARTEXT_SIZE); + var prop = new Properties(); + prop.put(UseToken.OWNER_KEY, owner); + prop.put(UseToken.LASTUPDATED_KEY, Instant.now().toString()); + prop.store(rawInfo, "Cryptomator Use Info"); + return encChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); + } } @Override @@ -181,4 +188,27 @@ interface EncryptionDecorator { WritableByteChannel wrapWithEncryption(ByteChannel ch, Cryptor c); } + + record NonClosingByteChannel(ByteChannel delegate) implements ByteChannel { + + @Override + public int write(ByteBuffer src) throws IOException { + return delegate.write(src); + } + + @Override + public boolean isOpen() { + return delegate.isOpen(); + } + + @Override + public void close() throws IOException { + //no-op + } + + @Override + public int read(ByteBuffer dst) throws IOException { + return delegate.read(dst); + } + } } From d7c65ee3dc091052a42f99a6eaaffb414e9e2ca7 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 17 Oct 2025 14:38:02 +0200 Subject: [PATCH 74/95] use a single virtual thread executor for creating in useFiles --- .../cryptofs/inuse/RealInUseManager.java | 15 ++++--- .../cryptofs/inuse/RealUseToken.java | 19 ++++----- .../cryptofs/inuse/RealInUseManagerTest.java | 33 +++++++++------- .../cryptofs/inuse/RealUseTokenTest.java | 39 +++++++++++-------- 4 files changed, 59 insertions(+), 47 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 2d8ebe57..7b4a8059 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -23,8 +23,8 @@ import java.time.temporal.ChronoUnit; import java.util.Optional; import java.util.Properties; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -44,6 +44,7 @@ public class RealInUseManager implements InUseManager { private final ConcurrentHashMap useTokens; private final Cache useInfoCache; private final Cache ignoredInUseFiles; + private final ExecutorService tokenPersistor; private final ScheduledExecutorService tokenRefresher; private final String owner; private final Cryptor cryptor; @@ -60,7 +61,8 @@ public RealInUseManager(String owner, Cryptor cryptor) { .expireAfterWrite(5, TimeUnit.SECONDS) // .maximumSize(1000) // .build(); - this.tokenRefresher = Executors.newSingleThreadScheduledExecutor(); + this.tokenPersistor = Executors.newVirtualThreadPerTaskExecutor(); + this.tokenRefresher = Executors.newSingleThreadScheduledExecutor(); //TODO: never closed -> resource leak tokenRefresher.scheduleWithFixedDelay(() -> useTokens.forEachValue(10L, RealUseToken::refresh), // REFRESH_DELAY_MINUTES, // REFRESH_DELAY_MINUTES, // @@ -190,15 +192,15 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { throw new FileAlreadyInUseException(inUseFilePath); } ignoredInUseFiles.invalidate(inUseFilePath); - return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); + return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens, tokenPersistor); } catch (NoSuchFileException e) { LOG.trace("No in-use-file {} found. Creating it.", inUseFilePath, e); - return RealUseToken.createWithNewFile(inUseFilePath, owner, cryptor, useTokens); + return RealUseToken.createWithNewFile(inUseFilePath, owner, cryptor, useTokens, tokenPersistor); } catch (IOException e) { throw new UncheckedIOException(e); //wrapped due to Map::compute method } catch (IllegalArgumentException e) { LOG.debug("Found invalid in-use-file {}. Owning it.", inUseFilePath, e); - return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens); + return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens, tokenPersistor); } } @@ -219,12 +221,13 @@ static Path computeInUseFilePath(Path p) { } //for testing - RealInUseManager(String owner, Cryptor cryptor, ConcurrentHashMap useTokens, Cache ignoredInUseFiles, Cache useInfoCache, ScheduledExecutorService tokenRefresher) { + RealInUseManager(String owner, Cryptor cryptor, ConcurrentHashMap useTokens, Cache ignoredInUseFiles, Cache useInfoCache, ExecutorService tokenPersistor, ScheduledExecutorService tokenRefresher) { this.owner = owner; this.cryptor = cryptor; this.useTokens = useTokens; this.ignoredInUseFiles = ignoredInUseFiles; this.useInfoCache = useInfoCache; + this.tokenPersistor = tokenPersistor; this.tokenRefresher = tokenRefresher; } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 5ff741ef..6533eed6 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -23,7 +23,7 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.Executors; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -35,12 +35,12 @@ */ public final class RealUseToken implements UseToken { - public static RealUseToken createWithNewFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { - return new RealUseToken(p, owner, cryptor, useTokens, StandardOpenOption.CREATE_NEW); + public static RealUseToken createWithNewFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor) { + return new RealUseToken(p, owner, cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW); } - public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens) { - return new RealUseToken(p, owner, cryptor, useTokens, StandardOpenOption.TRUNCATE_EXISTING); + public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor) { + return new RealUseToken(p, owner, cryptor, useTokens, tokenPersistor, StandardOpenOption.TRUNCATE_EXISTING); } private static final Logger LOG = LoggerFactory.getLogger(RealUseToken.class); @@ -56,11 +56,12 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor private volatile SeekableByteChannel channel; private volatile boolean closed; - RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, OpenOption openMode) { - this(filePath, owner, cryptor, useTokens, openMode, EncryptedChannels::wrapEncryptionAround); + RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor, OpenOption openMode) { + var delayedExecutor = CompletableFuture.delayedExecutor(Constants.INUSE_DELAY_MILLIS, TimeUnit.MILLISECONDS, tokenPersistor); + this(filePath, owner, cryptor, useTokens, delayedExecutor, openMode, EncryptedChannels::wrapEncryptionAround); } - RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, OpenOption openMode, EncryptionDecorator encWrapper) { + RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor, OpenOption openMode, EncryptionDecorator encWrapper) { this.owner = owner; this.filePath = filePath; this.cryptor = cryptor; @@ -68,7 +69,7 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor this.encWrapper = encWrapper; this.closed = false; var openOptions = Set.of(StandardOpenOption.WRITE, openMode); - this.creationTask = CompletableFuture.runAsync(() -> createInUseFile(openOptions), CompletableFuture.delayedExecutor(Constants.INUSE_DELAY_MILLIS, TimeUnit.MILLISECONDS, Executors.newVirtualThreadPerTaskExecutor())); + this.creationTask = CompletableFuture.runAsync(() -> createInUseFile(openOptions), tokenPersistor); } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 1e507ad3..f224a929 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -28,6 +28,7 @@ import java.time.temporal.ChronoUnit; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -38,12 +39,14 @@ public class RealInUseManagerTest { private Path ciphertextPath; private Path inUseFilePath; private Cryptor cryptor; + private ExecutorService tokenPersistor; @BeforeEach public void beforeEach() { ciphertextPath = mock(Path.class, "ciphertext.c9r"); inUseFilePath = mock(Path.class, "inUseFile.c9u"); cryptor = mock(Cryptor.class); + tokenPersistor = mock(ExecutorService.class); staticManagerMock = mockStatic(RealInUseManager.class); staticManagerMock.when(() -> RealInUseManager.computeInUseFilePath(ciphertextPath)).thenReturn(inUseFilePath); } @@ -55,7 +58,7 @@ public void testUseByOthersWithExistingToken() throws IOException { preparedMap.put(inUseFilePath, mock(RealUseToken.class)); var filesMarkedForStealing = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing, useInfoCache, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing, useInfoCache, tokenPersistor, null); var inUseSpy = spy(inUseManager); var result = inUseSpy.isInUseByOthers(ciphertextPath); @@ -135,19 +138,19 @@ public void testCreateExistingValid() throws IOException { var ignoredInUseFiles = mock(Cache.class); doNothing().when(ignoredInUseFiles).invalidate(inUseFilePath); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor, null); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { - staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); + staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap, tokenPersistor)).thenReturn(token); doReturn(false).when(inUseSpy).isInUse(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); verify(inUseSpy).isInUse(inUseFilePath); verify(ignoredInUseFiles).invalidate(inUseFilePath); - staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); + staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap, tokenPersistor)); } } @@ -157,18 +160,18 @@ public void testCreateExistingInvalid() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor, null); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { - staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); + staticUseTokenMock.when(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap, tokenPersistor)).thenReturn(token); doThrow(IllegalArgumentException.class).when(inUseSpy).isInUse(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); verify(inUseSpy).isInUse(inUseFilePath); - staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); + staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap, tokenPersistor)); } } @@ -178,18 +181,18 @@ public void testCreateNotExisting() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor, null); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); try (var staticUseTokenMock = mockStatic(RealUseToken.class)) { - staticUseTokenMock.when(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)).thenReturn(token); + staticUseTokenMock.when(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap, tokenPersistor)).thenReturn(token); doThrow(NoSuchFileException.class).when(inUseSpy).isInUse(inUseFilePath); var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); verify(inUseSpy).isInUse(inUseFilePath); - staticUseTokenMock.verify(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap)); + staticUseTokenMock.verify(() -> RealUseToken.createWithNewFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap, tokenPersistor)); } } @@ -199,7 +202,7 @@ public void testCreateFailedRead() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor, null); var inUseSpy = spy(inUseManager); doReturn(true).when(inUseSpy).isInUse(inUseFilePath); @@ -302,7 +305,7 @@ void hasSameOwner() { @Test @DisplayName("If the inUse properties have the lastUpdated timestamp below threshold, return true") void hasDifferentOwnerLastUpdatedBelowTreshold() { - var useInfo = new UseInfo("bob",Instant.now().minus(3, ChronoUnit.MINUTES)); + var useInfo = new UseInfo("bob", Instant.now().minus(3, ChronoUnit.MINUTES)); var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var result = inUseManager.isInUse(useInfo); @@ -313,7 +316,7 @@ void hasDifferentOwnerLastUpdatedBelowTreshold() { @Test @DisplayName("If the inUse properties have the lastUpdated timestamp above threshold, return true") void hasDifferentOwnerLastUpdatedAboveThreshold() { - var useInfo = new UseInfo("bob",Instant.now().minus(20, ChronoUnit.MINUTES)); + var useInfo = new UseInfo("bob", Instant.now().minus(20, ChronoUnit.MINUTES)); var inUseManager = new RealInUseManager("cryptobot3000", cryptor); var result = inUseManager.isInUse(useInfo); @@ -330,7 +333,7 @@ void isInUseChecksIgnoredCache() throws IOException { var ignoredInUseFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); doReturn(Boolean.TRUE).when(ignoredInUseFiles).getIfPresent(inUseFilePath); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor, null); var result = inUseManager.isInUse(inUseFilePath); @@ -347,7 +350,7 @@ void isInUseChecksUseInfoCache() throws IOException { var useInfoCache = mock(Cache.class); var useInfo = new UseInfo("bob", Instant.now()); doReturn(useInfo).when(useInfoCache).get(eq(inUseFilePath), any()); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor, null); var result = inUseManager.isInUse(inUseFilePath); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index 0b1f71b5..4a43d710 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -1,7 +1,6 @@ package org.cryptomator.cryptofs.inuse; import org.awaitility.Awaitility; -import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptolib.api.Cryptor; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; @@ -19,8 +18,12 @@ import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchService; import java.time.Duration; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -32,12 +35,13 @@ public class RealUseTokenTest { private ConcurrentMap useTokens; private Cryptor cryptor; private RealUseToken.EncryptionDecorator encWrapper; + private Executor tokenPersistor; @TempDir Path tmpDir; private WatchService watchService; - - private static final Duration FILE_OPERATION_DELAY = Duration.ofMillis(Constants.INUSE_DELAY_MILLIS - 100); - private static final Duration FILE_OPERATION_MAX = FILE_OPERATION_DELAY.plusMillis(3000); + private static final long FILE_OPERATION_DELAY_MILLIS = 1000L; + private static final Duration FILE_OPERATION_DELAY = Duration.ofMillis(FILE_OPERATION_DELAY_MILLIS-100L); //allow some leeway + private static final Duration FILE_OPERATION_MAX = FILE_OPERATION_DELAY.plusMillis(2000L); @BeforeEach public void beforeEach() throws IOException { @@ -45,6 +49,7 @@ public void beforeEach() throws IOException { encWrapper = mock(RealUseToken.EncryptionDecorator.class); useTokens = new ConcurrentHashMap<>(); watchService = tmpDir.getFileSystem().newWatchService(); + tokenPersistor = CompletableFuture.delayedExecutor(FILE_OPERATION_DELAY_MILLIS, TimeUnit.MILLISECONDS); doAnswer(invocation -> invocation.getArgument(0)) //just return the real file channel .when(encWrapper).wrapWithEncryption(any(), eq(cryptor)); @@ -60,10 +65,10 @@ public void afterEach() { } @Test - @DisplayName("After 5 seconds of token creation, a new file is created") + @DisplayName("After X seconds of token creation, a new file is created") public void testFileCreation() { var filePath = tmpDir.resolve("inUse.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); Assertions.assertTrue(Files.exists(filePath)); } @@ -71,14 +76,14 @@ public void testFileCreation() { } @Test - @DisplayName("After 5 seconds of token creation, a file is updated") + @DisplayName("After X seconds of token creation, a file is updated") public void testFileSteal() throws IOException { var filePath = tmpDir.resolve("inUse.file"); Files.createFile(filePath); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); var fileTime = Files.getLastModifiedTime(filePath); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); var events = watchKey.pollEvents(); var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); @@ -92,12 +97,12 @@ public void testFileSteal() throws IOException { } @Test - @DisplayName("After 5 seconds of token creation, failed steal closes the token ") + @DisplayName("After X seconds of token creation, failed steal closes the token ") public void testFileStealFails() throws IOException { var filePath = tmpDir.resolve("inUse.file"); //file does not exist var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(token::isClosed); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); @@ -107,12 +112,12 @@ public void testFileStealFails() throws IOException { } @Test - @DisplayName("Closing a UseToken before 5 seconds passed skips file creation") + @DisplayName("Closing a UseToken before X seconds passed skips file creation") public void testTokenCloseBeforeFileOperation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { Assertions.assertTrue(Files.notExists(filePath)); } Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); @@ -128,7 +133,7 @@ public void testMoveToBefore() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { token.moveToInternal(targetPath); //no file operation after move @@ -155,10 +160,10 @@ public void testMoveToBefore() throws IOException { @Test @DisplayName("Moving a token after file creation") public void testMoveToAfter() { - var filePath = tmpDir.resolve("inUse.file"); - var targetPath = tmpDir.resolve("inUse2.file"); + var filePath = tmpDir.resolve("inUseMove.file"); + var targetPath = tmpDir.resolve("inUseMove2.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); token.moveToInternal(targetPath); @@ -181,7 +186,7 @@ public void testMoveToClosed() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { token.close(); Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); From 19e189cd49db3e35d914edda9c7913f632095c58 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 17 Oct 2025 14:39:13 +0200 Subject: [PATCH 75/95] follow a more idiomatic programming style accquiring locks --- .../org/cryptomator/cryptofs/inuse/RealUseToken.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 6533eed6..e39fe4d1 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -74,8 +74,8 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor } private void createInUseFile(Set openOptions) { + fileCreationSync.lock(); try { - fileCreationSync.lock(); if (closed) { return; } @@ -91,8 +91,8 @@ private void createInUseFile(Set openOptions) { } void refresh() { + fileCreationSync.lock(); try { - fileCreationSync.lock(); if (closed || channel == null) { return; } @@ -125,10 +125,8 @@ public void moveTo(Path newCiphertextPath) { //visible for testing void moveToInternal(Path newFilePath) { + fileCreationSync.lock(); try { - //sync with file creation - fileCreationSync.lock(); - if (closed) { return; } @@ -159,10 +157,8 @@ public boolean isClosed() { @Override public void close() { + fileCreationSync.lock(); try { - //sync with file creation - fileCreationSync.lock(); - if (closed) { return; } From 270cf113bfe4ee920861d705c0802a8f923add5b Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 17 Oct 2025 14:40:39 +0200 Subject: [PATCH 76/95] doc doc doc --- .../java/org/cryptomator/cryptofs/inuse/InUseManager.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index 236a69f8..fdf0cfc5 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -45,6 +45,11 @@ default UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { return UseToken.INIT_TOKEN; } + /** + * Ignore any existing in-use-files for the give ciphertext path for a limited amount of time. + * + * @param ciphertextPath Path to the ciphertext file + */ default void ignoreInUse(Path ciphertextPath) { } From a9bd0a02a38e4115d5f091b2ae5ecaf0db785ec6 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 17 Oct 2025 14:59:27 +0200 Subject: [PATCH 77/95] only create actual token when a filechannel is opened for writing --- .../cryptofs/fh/OpenCryptoFile.java | 3 +- .../cryptofs/fh/OpenCryptoFileTest.java | 29 +++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index a89aa2ec..4b02690f 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -82,8 +82,7 @@ public synchronized FileChannel newFileChannel(EffectiveOpenOptions options, Fil openChannelsCount.incrementAndGet(); // synchronized context, hence we can proactively increase the number try { - //TODO: what about read-only file channels? Then we need to update logic, that first writable channel needs to create this file - if (useToken.isClosed()) { //the token was closed prematurely, so we try to get a new one + if (options.writable() && useToken.isClosed()) { //the token was closed prematurely, so we try to get a new one useToken = inUseManager.use(path); } ciphertextFileChannel = path.getFileSystem().provider().newFileChannel(path, options.createOpenOptionsForEncryptedFile(), attrs); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java index 7fc9b25e..3794f480 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/OpenCryptoFileTest.java @@ -36,6 +36,7 @@ import java.nio.file.attribute.PosixFilePermissions; import java.time.Instant; import java.util.EnumSet; +import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -140,6 +141,28 @@ public void testNewFileChannelOpenUseToken() throws IOException { verify(inUseManager, never()).use(expectedCiphertextPath); } + @Test + @DisplayName("if the file is openend only for reading, don't check usage") + public void testIgnoreUsageForReadonly() throws IOException { + var openCryptoFile = spy(getTestInstance("testNewFileChannelIgnoreUsage")); + var expectedCiphertextPath = CURRENT_FILE_PATH.get(); + Files.createFile(expectedCiphertextPath); + + EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.READ), readonlyFlag); + var cleartextChannel = mock(CleartextFileChannel.class); + Mockito.when(headerHolder.get()).thenReturn(Mockito.mock(FileHeader.class)); + Mockito.when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(42); + Mockito.when(openCryptoFileComponent.newChannelComponent()).thenReturn(channelComponentFactory); + Mockito.when(channelComponentFactory.create(any(), any(), any())).thenReturn(channelComponent); + Mockito.when(channelComponent.channel()).thenReturn(cleartextChannel); + when(useToken.isClosed()).thenReturn(true); + + openCryptoFile.newFileChannel(options); + + verify(inUseManager, never()).use(expectedCiphertextPath); + } + @Test @DisplayName("if useToken is closed, get a new one") public void testNewFileChannelClosedToken() throws IOException { @@ -163,12 +186,12 @@ public void testNewFileChannelClosedToken() throws IOException { } @Test - @DisplayName("if the file is in use, throw exception") - public void testInUseFileThrowsException() throws FileAlreadyInUseException { + @DisplayName("if the file is in use and file is opened for writing, throw exception") + public void testInUseFileThrowsException() throws IOException { var openCryptoFile = spy(getTestInstance("testInUseFileThrowsException")); var expectedCiphertextPath = CURRENT_FILE_PATH.get(); - EffectiveOpenOptions options = Mockito.mock(EffectiveOpenOptions.class); + EffectiveOpenOptions options = EffectiveOpenOptions.from(EnumSet.of(StandardOpenOption.WRITE), readonlyFlag); when(useToken.isClosed()).thenReturn(true); when(inUseManager.use(expectedCiphertextPath)).thenThrow(FileAlreadyInUseException.class); From b40d1830e74fdc4f3a5dcff35a35106e502e38b9 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 17 Oct 2025 15:08:16 +0200 Subject: [PATCH 78/95] remove unnecessary comment string in decrypted in-use-file --- src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index e39fe4d1..aceaf0f0 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -112,7 +112,7 @@ int writeInUseFile() throws IOException { var prop = new Properties(); prop.put(UseToken.OWNER_KEY, owner); prop.put(UseToken.LASTUPDATED_KEY, Instant.now().toString()); - prop.store(rawInfo, "Cryptomator Use Info"); + prop.store(rawInfo, null); return encChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); } } From fe9bbc7d49bc5910e8e2a212a45081989581b760 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 17 Oct 2025 15:18:36 +0200 Subject: [PATCH 79/95] avoid typecasting --- .../java/org/cryptomator/cryptofs/inuse/RealInUseManager.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 7b4a8059..ca91295c 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -131,12 +131,12 @@ Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgument //TODO: test UseInfo validate(Properties content) throws IllegalArgumentException { - var ownerFromFile = (String) content.get(UseToken.OWNER_KEY); + var ownerFromFile = content.getProperty(UseToken.OWNER_KEY); if (ownerFromFile == null || ownerFromFile.isBlank()) { throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.OWNER_KEY)); } - var stringTime = (String) content.get(UseToken.LASTUPDATED_KEY); + var stringTime = content.getProperty(UseToken.LASTUPDATED_KEY); if (stringTime == null) { throw new IllegalArgumentException("Invalid in-use-file. Missing key %s".formatted(UseToken.LASTUPDATED_KEY)); } From b65c0ea135eec75b003b04ede628b59434f51783 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 17 Oct 2025 15:30:54 +0200 Subject: [PATCH 80/95] add more tests to RealUseToken --- .../cryptofs/inuse/RealUseTokenTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index 4a43d710..bfac7078 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -18,6 +19,8 @@ import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchService; import java.time.Duration; +import java.time.Instant; +import java.util.Properties; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -29,6 +32,8 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; public class RealUseTokenTest { @@ -75,6 +80,21 @@ public void testFileCreation() { Assertions.assertTrue(Files.notExists(filePath)); } + @Test + @DisplayName("The properties file contains required keys with valid content") + public void testFileContent() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); + + var props = new Properties(); + var rawProps = Files.readAllBytes(filePath); + props.load(new ByteArrayInputStream(rawProps)); + Assertions.assertEquals("test3000", props.getProperty(UseToken.OWNER_KEY)); + Assertions.assertDoesNotThrow(() -> Instant.parse(props.getProperty(UseToken.LASTUPDATED_KEY))); + } + } + @Test @DisplayName("After X seconds of token creation, a file is updated") public void testFileSteal() throws IOException { @@ -198,4 +218,40 @@ public void testMoveToClosed() throws IOException { Assertions.assertNull(useTokens.get(targetPath)); } } + + @Test + @DisplayName("After token persisting, refreshing a token modifies content") + public void testFileRefresh() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); + + var props = new Properties(); + var rawProps = Files.readAllBytes(filePath); + props.load(new ByteArrayInputStream(rawProps)); + var oldLastUpdated = Instant.parse(props.getProperty(UseToken.LASTUPDATED_KEY)); + + token.refresh(); + + var props2 = new Properties(); + rawProps = Files.readAllBytes(filePath); + props2.load(new ByteArrayInputStream(rawProps)); + + Assertions.assertEquals("test3000", props2.getProperty(UseToken.OWNER_KEY)); + var newLastUpdated = Instant.parse(props2.getProperty(UseToken.LASTUPDATED_KEY)); + Assertions.assertTrue(newLastUpdated.isAfter(oldLastUpdated)); + } + } + + @Test + @DisplayName("Before token persisting, refreshing a token does nothing") + public void testFileRefreshSkip() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + token.refresh(); + verify(encWrapper, never()).wrapWithEncryption(any(), eq(cryptor)); + } + } } From 7a2b5480547cf26cd12b93404aff7e2f22715adb Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 17 Oct 2025 17:01:34 +0200 Subject: [PATCH 81/95] Remove CryptoPath from FileIsInUseEvent --- .../cryptofs/event/FileIsInUseEvent.java | 18 +++++++++++------- .../cryptofs/CryptoFileSystemImplTest.java | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java b/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java index 66b1af0d..5433d43f 100644 --- a/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java +++ b/src/main/java/org/cryptomator/cryptofs/event/FileIsInUseEvent.java @@ -3,10 +3,18 @@ import java.nio.file.Path; import java.time.Instant; -public record FileIsInUseEvent(Instant timestamp, Path cleartext, Path ciphertext, String owner, Instant lastUpdated, Runnable ignoreMethod) implements FilesystemEvent { +/** + * @param timestamp timestamp of event appearance + * @param cleartextPath path (string) within the cryptographic filesystem + * @param ciphertextPath path to the encrypted file + * @param owner Name of the owner of the in-use-file + * @param lastUpdated Time of last in-use-file update + * @param ignoreMethod Method to ignore the use-status of the encrypted file for a short duration + */ +public record FileIsInUseEvent(Instant timestamp, String cleartextPath, Path ciphertextPath, String owner, Instant lastUpdated, Runnable ignoreMethod) implements FilesystemEvent { - public FileIsInUseEvent(Path cleartext, Path ciphertext, String owner, Instant lastUpdated, Runnable ignoreMethod) { - this(Instant.now(), cleartext, ciphertext, owner, lastUpdated, ignoreMethod); + public FileIsInUseEvent(Path cleartextPath, Path ciphertextPath, String owner, Instant lastUpdated, Runnable ignoreMethod) { + this(Instant.now(), cleartextPath.toString(), ciphertextPath, owner, lastUpdated, ignoreMethod); } @Override @@ -14,8 +22,4 @@ public Instant getTimestamp() { return timestamp; } - public void ignoreInUse() { - ignoreMethod.run(); - } - } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index a05d250f..62d9d6b8 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -452,7 +452,7 @@ public void testCheckUsageThrowsException() throws FileAlreadyInUseException { when(inUseManager.isInUseByOthers(ciphertextFilePath)).thenReturn(true); Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.checkUsage(cleartextPath, ciphertextPath)); - var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent && ((FileIsInUseEvent) ev).cleartext().equals(cleartextPath); + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent && ((FileIsInUseEvent) ev).cleartextPath().equals(cleartextPath.toString()); verify(inUseManager).isInUseByOthers(ciphertextFilePath); verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); } @@ -592,7 +592,7 @@ public void testNewFileChannelInUseFailure() throws IOException { when(openCryptoFile.newFileChannel(any())).thenThrow(FileAlreadyInUseException.class); Assertions.assertThrows(FileAlreadyInUseException.class, () -> inTest.newFileChannel(cleartextPath, EnumSet.of(StandardOpenOption.WRITE))); - var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent && ((FileIsInUseEvent) ev).cleartext().equals(cleartextPath); + var isFileIsInUseEvent = (ArgumentMatcher) ev -> ev instanceof FileIsInUseEvent && ((FileIsInUseEvent) ev).cleartextPath().equals(cleartextPath.toString()); verify(eventConsumer).accept(ArgumentMatchers.argThat(isFileIsInUseEvent)); } From 819a4142053b98b6a4da059ff11daec33b6e6107 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 21 Oct 2025 18:29:55 +0200 Subject: [PATCH 82/95] fix ignoring mechanism --- .../cryptomator/cryptofs/inuse/RealInUseManager.java | 7 +++++-- .../cryptofs/inuse/RealInUseManagerTest.java | 10 +++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index ca91295c..38de6e81 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -2,6 +2,7 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Scheduler; import org.cryptomator.cryptofs.common.CacheUtils; import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.EncryptedChannels; @@ -55,6 +56,7 @@ public RealInUseManager(String owner, Cryptor cryptor) { this.useTokens = new ConcurrentHashMap<>(); this.ignoredInUseFiles = Caffeine.newBuilder() // .expireAfterWrite(2, TimeUnit.MINUTES) //Do not keep the mark too long + .scheduler(Scheduler.systemScheduler()) // .maximumSize(100) // .build(); this.useInfoCache = Caffeine.newBuilder() // @@ -175,7 +177,9 @@ public Optional getUseInfo(Path ciphertextPath) { public UseToken use(Path ciphertextPath) throws FileAlreadyInUseException { var inUseFilePath = computeInUseFilePath(ciphertextPath); try { - return useTokens.computeIfAbsent(inUseFilePath, this::createInternal); + var token = useTokens.computeIfAbsent(inUseFilePath, this::createInternal); + ignoredInUseFiles.invalidate(inUseFilePath); + return token; } catch (UncheckedIOException e) { if (e.getCause() instanceof FileAlreadyInUseException inUseExc) { throw inUseExc; @@ -191,7 +195,6 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { if (isInUse(inUseFilePath)) { throw new FileAlreadyInUseException(inUseFilePath); } - ignoredInUseFiles.invalidate(inUseFilePath); return RealUseToken.createWithExistingFile(inUseFilePath, owner, cryptor, useTokens, tokenPersistor); } catch (NoSuchFileException e) { LOG.trace("No in-use-file {} found. Creating it.", inUseFilePath, e); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index f224a929..8baa760b 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -95,7 +95,10 @@ public void testUseByOthersException(Class exceptionClass) throws IOException { @Test @DisplayName("\"use\" method puts path into map and returns token") public void testUsePutsPathInMap() throws FileAlreadyInUseException { - var inUseManager = new RealInUseManager("cryptobot3000", cryptor); + var preparedMap = new ConcurrentHashMap(); + var ignoredFiles = mock(Cache.class); + var useInfoCache = mock(Cache.class); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor, null); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -103,6 +106,9 @@ public void testUsePutsPathInMap() throws FileAlreadyInUseException { var result = inUseSpy.use(ciphertextPath); Assertions.assertSame(token, result); + Assertions.assertSame(token, preparedMap.get(inUseFilePath)); + verify(ignoredFiles).invalidate(inUseFilePath); + verify(inUseSpy).createInternal(inUseFilePath); } @Test @@ -136,7 +142,6 @@ public void testUseClosedToken() { public void testCreateExistingValid() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredInUseFiles = mock(Cache.class); - doNothing().when(ignoredInUseFiles).invalidate(inUseFilePath); var useInfoCache = mock(Cache.class); var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor, null); var inUseSpy = spy(inUseManager); @@ -149,7 +154,6 @@ public void testCreateExistingValid() throws IOException { var result = inUseSpy.createInternal(inUseFilePath); Assertions.assertSame(token, result); verify(inUseSpy).isInUse(inUseFilePath); - verify(ignoredInUseFiles).invalidate(inUseFilePath); staticUseTokenMock.verify(() -> RealUseToken.createWithExistingFile(inUseFilePath, "cryptobot3000", cryptor, preparedMap, tokenPersistor)); } } From cd7bf0966c1af5f766f86f779459755ab64aa0c5 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 21 Oct 2025 18:33:24 +0200 Subject: [PATCH 83/95] close executor services on filesystem.close() --- .../cryptofs/CryptoFileSystemImpl.java | 8 +++++--- .../cryptofs/inuse/InUseManager.java | 3 ++- .../cryptofs/inuse/RealInUseManager.java | 19 +++++++++++++++++++ .../cryptofs/inuse/StubInUseManager.java | 6 ++++++ .../cryptofs/CryptoFileSystemImplTest.java | 19 +++++++++++++++++-- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index c6930855..4c2b2e37 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -213,9 +213,11 @@ public void close() throws IOException { open = false; finallyUtil.guaranteeInvocationOf( // () -> cryptoFileSystems.remove(this), // - () -> openCryptoFiles.close(), // - () -> directoryStreamFactory.close(), // - () -> cryptor.destroy()); + openCryptoFiles::close, // + directoryStreamFactory::close, // + inUseManager::close, // + cryptor::destroy // + ); } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java index fdf0cfc5..118a9906 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/InUseManager.java @@ -1,5 +1,6 @@ package org.cryptomator.cryptofs.inuse; +import java.io.Closeable; import java.nio.file.Path; import java.util.Optional; @@ -11,7 +12,7 @@ *

  • ignore the in-use-file for a ciphertext path
  • * */ -public interface InUseManager { +public interface InUseManager extends Closeable { /** * Checks if the given ciphertext path is used by others. diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 38de6e81..627d4f9a 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -213,6 +213,25 @@ public void ignoreInUse(Path ciphertextPath) { ignoredInUseFiles.put(inUseFilePath, Boolean.TRUE); } + @Override + public void close() throws IOException { + tokenRefresher.shutdown(); + tokenPersistor.shutdown(); + try { + if (!tokenRefresher.awaitTermination(5, TimeUnit.SECONDS)) { + tokenRefresher.shutdownNow(); + } + if (!tokenPersistor.awaitTermination(5, TimeUnit.SECONDS)) { + tokenPersistor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + tokenRefresher.shutdownNow(); + tokenPersistor.shutdownNow(); + } + + } + /** * @param p a path with a filename ending with {@value Constants#CRYPTOMATOR_FILE_SUFFIX} * @return a sibling path with the file extension {@value Constants#INUSE_FILE_SUFFIX} diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java index 8af2a1f5..4d732c3a 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/StubInUseManager.java @@ -1,5 +1,11 @@ package org.cryptomator.cryptofs.inuse; +import java.io.IOException; + public class StubInUseManager implements InUseManager { + @Override + public void close() throws IOException { + + } } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 62d9d6b8..ccbd90c9 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -288,7 +288,11 @@ public void setup() { ((RunnableThrowingException) runnable).run(); } return null; - }).when(finallyUtil).guaranteeInvocationOf(any(RunnableThrowingException.class), any(RunnableThrowingException.class), any(RunnableThrowingException.class), any(RunnableThrowingException.class)); + }).when(finallyUtil).guaranteeInvocationOf(any(RunnableThrowingException.class), + any(RunnableThrowingException.class), + any(RunnableThrowingException.class), + any(RunnableThrowingException.class), + any(RunnableThrowingException.class)); } @Test @@ -298,6 +302,13 @@ public void testCloseRemovesThisFromCryptoFileSystems() throws IOException { verify(cryptoFileSystems).remove(inTest); } + @Test + public void testClosesInUseManager() throws IOException { + inTest.close(); + + verify(inUseManager).close(); + } + @Test public void testCloseDestroysCryptor() throws IOException { inTest.close(); @@ -353,7 +364,11 @@ public void setup() { ((RunnableThrowingException) runnable).run(); } return null; - }).when(finallyUtil).guaranteeInvocationOf(any(RunnableThrowingException.class), any(RunnableThrowingException.class), any(RunnableThrowingException.class), any(RunnableThrowingException.class)); + }).when(finallyUtil).guaranteeInvocationOf(any(RunnableThrowingException.class), // + any(RunnableThrowingException.class), // + any(RunnableThrowingException.class), // + any(RunnableThrowingException.class), // + any(RunnableThrowingException.class)); } @Test From c2089abf409c8d739516c2a119fb82d865b8ec54 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 22 Oct 2025 09:50:19 +0200 Subject: [PATCH 84/95] adjust pom to coding style --- pom.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 29906a49..1ef84b36 100644 --- a/pom.xml +++ b/pom.xml @@ -25,6 +25,7 @@ 2.0.17 + 4.3.0 6.0.0 1.37 5.20.0 @@ -162,7 +163,7 @@ org.awaitility awaitility - 4.3.0 + ${awaitility.version} test From 7d3998aec76621baf7d2b50a35796ca947a8b547 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 22 Oct 2025 09:52:26 +0200 Subject: [PATCH 85/95] fix test description --- .../java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index ccbd90c9..332d43f6 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -473,7 +473,7 @@ public void testCheckUsageThrowsException() throws FileAlreadyInUseException { } @Test - @DisplayName("checkUsage throws exception when file is in-use") + @DisplayName("checkUsage does nothing if file is not in-use") public void testCheckUsageForNotInUseFiles() throws FileAlreadyInUseException { CryptoPath cleartextPath = mock(CryptoPath.class, "cleartext"); CryptoPath ciphertextFilePath = mock(CryptoPath.class, "d/00/00/path.c9r"); From bd9032f5d39314cd2f2a5b3d87f6e699fce674d4 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 22 Oct 2025 15:12:25 +0200 Subject: [PATCH 86/95] Force writings to inUseFile --- .../cryptomator/cryptofs/inuse/RealUseToken.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index aceaf0f0..de70ade7 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -11,7 +11,7 @@ import java.io.UncheckedIOException; import java.nio.ByteBuffer; import java.nio.channels.ByteChannel; -import java.nio.channels.SeekableByteChannel; +import java.nio.channels.FileChannel; import java.nio.channels.WritableByteChannel; import java.nio.file.Files; import java.nio.file.OpenOption; @@ -53,7 +53,7 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor private final ReentrantReadWriteLock.WriteLock fileCreationSync = new ReentrantReadWriteLock().writeLock(); private volatile Path filePath; - private volatile SeekableByteChannel channel; + private volatile FileChannel channel; private volatile boolean closed; RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor, OpenOption openMode) { @@ -79,7 +79,7 @@ private void createInUseFile(Set openOptions) { if (closed) { return; } - this.channel = Files.newByteChannel(filePath, openOptions); + this.channel = FileChannel.open(filePath, openOptions); writeInUseFile(); } catch (IOException e) { LOG.debug("Failed to write in-use file {} with open options {}.", filePath, openOptions, e); @@ -97,7 +97,6 @@ void refresh() { return; } writeInUseFile(); - channel.position(0); } catch (IOException e) { LOG.debug("Failed to refresh in-use file {}.", filePath, e); } finally { @@ -106,6 +105,8 @@ void refresh() { } int writeInUseFile() throws IOException { + channel.position(0); + final int bytesWritten; try (var nonClosingWrapper = new NonClosingByteChannel(channel); // var encChannel = encWrapper.wrapWithEncryption(nonClosingWrapper, cryptor)) { var rawInfo = new ByteArrayOutputStream(Constants.INUSE_CLEARTEXT_SIZE); @@ -113,8 +114,10 @@ int writeInUseFile() throws IOException { prop.put(UseToken.OWNER_KEY, owner); prop.put(UseToken.LASTUPDATED_KEY, Instant.now().toString()); prop.store(rawInfo, null); - return encChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); + bytesWritten = encChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); } + channel.force(false); + return bytesWritten; } @Override From 344846908297f2cbccb92add6b73c94d813212f8 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 22 Oct 2025 15:13:02 +0200 Subject: [PATCH 87/95] Remove TODO --- .../java/org/cryptomator/cryptofs/inuse/RealInUseManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 627d4f9a..c9454146 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -64,7 +64,7 @@ public RealInUseManager(String owner, Cryptor cryptor) { .maximumSize(1000) // .build(); this.tokenPersistor = Executors.newVirtualThreadPerTaskExecutor(); - this.tokenRefresher = Executors.newSingleThreadScheduledExecutor(); //TODO: never closed -> resource leak + this.tokenRefresher = Executors.newSingleThreadScheduledExecutor(); tokenRefresher.scheduleWithFixedDelay(() -> useTokens.forEachValue(10L, RealUseToken::refresh), // REFRESH_DELAY_MINUTES, // REFRESH_DELAY_MINUTES, // From ebe8c6e77d1a5de794d1e83951cd3f743f0e233c Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 22 Oct 2025 17:00:31 +0200 Subject: [PATCH 88/95] always truncate inUse file before writing --- src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index de70ade7..434d4510 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -105,7 +105,7 @@ void refresh() { } int writeInUseFile() throws IOException { - channel.position(0); + channel.truncate(0); final int bytesWritten; try (var nonClosingWrapper = new NonClosingByteChannel(channel); // var encChannel = encWrapper.wrapWithEncryption(nonClosingWrapper, cryptor)) { From eba5f29b7ac0598407c21833d11a107bee731541 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 28 Oct 2025 16:50:57 +0100 Subject: [PATCH 89/95] Remove conflicting c9u files on directory listing --- .../cryptofs/common/Constants.java | 4 ++ .../cryptofs/dir/C9rDecryptor.java | 3 +- .../cryptofs/dir/C9uConflictResolver.java | 57 +++++++++++++++++++ .../cryptofs/dir/C9uProcessor.java | 24 ++++++++ .../cryptofs/dir/NodeProcessor.java | 6 +- .../cryptofs/dir/C9rDecryptorTest.java | 5 +- .../cryptofs/dir/C9uConflictResolverTest.java | 52 +++++++++++++++++ 7 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/dir/C9uConflictResolver.java create mode 100644 src/main/java/org/cryptomator/cryptofs/dir/C9uProcessor.java create mode 100644 src/test/java/org/cryptomator/cryptofs/dir/C9uConflictResolverTest.java diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 2b097749..06a613d7 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -8,6 +8,8 @@ *******************************************************************************/ package org.cryptomator.cryptofs.common; +import java.util.regex.Pattern; + public final class Constants { private Constants() { @@ -38,4 +40,6 @@ private Constants() { public static final String RECOVERY_DIR_NAME = "LOST+FOUND"; public static final int INUSE_DELAY_MILLIS = 5000; public static final int INUSE_CLEARTEXT_SIZE = 1000; //calculation: Create inUse properties with owner consisting of \u2741.repeat(100) and encode it. Plus an additional buffer for future entries. + + public static final Pattern BASE64_PATTERN = Pattern.compile("[a-zA-Z0-9-_]{20}(?:[a-zA-Z0-9-_]{4})*(?:[a-zA-Z0-9-_]{4}|[a-zA-Z0-9-_]{3}=|[a-zA-Z0-9-_]{2}==)"); } diff --git a/src/main/java/org/cryptomator/cryptofs/dir/C9rDecryptor.java b/src/main/java/org/cryptomator/cryptofs/dir/C9rDecryptor.java index ee431197..1805d9d2 100644 --- a/src/main/java/org/cryptomator/cryptofs/dir/C9rDecryptor.java +++ b/src/main/java/org/cryptomator/cryptofs/dir/C9rDecryptor.java @@ -16,11 +16,12 @@ import java.util.regex.Pattern; import java.util.stream.Stream; +import static org.cryptomator.cryptofs.common.Constants.BASE64_PATTERN; + @DirectoryStreamScoped class C9rDecryptor { // visible for testing: - static final Pattern BASE64_PATTERN = Pattern.compile("[a-zA-Z0-9-_]{20}(?:[a-zA-Z0-9-_]{4})*(?:[a-zA-Z0-9-_]{4}|[a-zA-Z0-9-_]{3}=|[a-zA-Z0-9-_]{2}==)"); private static final CharMatcher DELIM_MATCHER = CharMatcher.anyOf("_-"); private final Cryptor cryptor; diff --git a/src/main/java/org/cryptomator/cryptofs/dir/C9uConflictResolver.java b/src/main/java/org/cryptomator/cryptofs/dir/C9uConflictResolver.java new file mode 100644 index 00000000..3f5f5792 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/dir/C9uConflictResolver.java @@ -0,0 +1,57 @@ +package org.cryptomator.cryptofs.dir; + +import jakarta.inject.Inject; +import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.common.StringUtils; +import org.cryptomator.cryptofs.inuse.InUseManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.regex.Matcher; +import java.util.stream.Stream; + +import static org.cryptomator.cryptofs.common.Constants.BASE64_PATTERN; + +/** + * Resolves in-use file conflicts. + */ +@DirectoryStreamScoped +public class C9uConflictResolver { + + private static final Logger LOG = LoggerFactory.getLogger(C9uConflictResolver.class); + + + private final InUseManager inUseManager; + + @Inject + public C9uConflictResolver(InUseManager inUseManager) { + this.inUseManager = inUseManager; + } + + /** + * Processes files with {@value Constants#INUSE_FILE_SUFFIX} file extension. (in-use files) + *

    + * If the in-use file is not valid bas64 encoding, delete the file. + * + * @param node + * @return an empty stream. + */ + Stream process(Node node) { + String basename = StringUtils.removeEnd(node.fullCiphertextFileName, Constants.INUSE_FILE_SUFFIX); + Matcher matcher = BASE64_PATTERN.matcher(basename); + matcher.region(0, basename.length()); + if (!matcher.matches()) { //any rename is considered bad + //TODO: close UseToken (if existent) + LOG.debug("Found renamed in-use-file {}. Deleting it.", node.ciphertextPath); + try { + Files.deleteIfExists(node.ciphertextPath); + } catch (IOException e) { + LOG.debug("Failed to delete in-use-file {}. Retry on next directory listing.", node.ciphertextPath); + } + } + return Stream.empty(); + } + +} diff --git a/src/main/java/org/cryptomator/cryptofs/dir/C9uProcessor.java b/src/main/java/org/cryptomator/cryptofs/dir/C9uProcessor.java new file mode 100644 index 00000000..29b402e6 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/dir/C9uProcessor.java @@ -0,0 +1,24 @@ +package org.cryptomator.cryptofs.dir; + +import jakarta.inject.Inject; +import org.cryptomator.cryptofs.common.Constants; + +import java.util.stream.Stream; + +/** + * Processes in-use files (file extension {@value Constants#INUSE_FILE_SUFFIX}. + */ +@DirectoryStreamScoped +public class C9uProcessor { + + private final C9uConflictResolver conflictRemover; + + @Inject + public C9uProcessor(C9uConflictResolver conflictRemover) { + this.conflictRemover = conflictRemover; + } + + public Stream process(Node node) { + return conflictRemover.process(node); + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/dir/NodeProcessor.java b/src/main/java/org/cryptomator/cryptofs/dir/NodeProcessor.java index c84c04d9..9c04d9cc 100644 --- a/src/main/java/org/cryptomator/cryptofs/dir/NodeProcessor.java +++ b/src/main/java/org/cryptomator/cryptofs/dir/NodeProcessor.java @@ -10,12 +10,14 @@ class NodeProcessor { private final C9rProcessor c9rProcessor; private final C9sProcessor c9sProcessor; + private final C9uProcessor c9uProcessor; private final BrokenDirectoryFilter brokenDirFilter; @Inject - public NodeProcessor(C9rProcessor c9rProcessor, C9sProcessor c9sProcessor, BrokenDirectoryFilter brokenDirFilter){ + public NodeProcessor(C9rProcessor c9rProcessor, C9sProcessor c9sProcessor, C9uProcessor c9uProcessor, BrokenDirectoryFilter brokenDirFilter){ this.c9rProcessor = c9rProcessor; this.c9sProcessor = c9sProcessor; + this.c9uProcessor = c9uProcessor; this.brokenDirFilter = brokenDirFilter; } @@ -24,6 +26,8 @@ public Stream process(Node node) { return c9rProcessor.process(node).flatMap(brokenDirFilter::process); } else if (node.fullCiphertextFileName.endsWith(Constants.DEFLATED_FILE_SUFFIX)) { return c9sProcessor.process(node).flatMap(brokenDirFilter::process); + } else if (node.fullCiphertextFileName.endsWith(Constants.INUSE_FILE_SUFFIX)) { + return c9uProcessor.process(node).flatMap(brokenDirFilter::process); } else { return Stream.empty(); } diff --git a/src/test/java/org/cryptomator/cryptofs/dir/C9rDecryptorTest.java b/src/test/java/org/cryptomator/cryptofs/dir/C9rDecryptorTest.java index 14c60c84..5b7d5ba7 100644 --- a/src/test/java/org/cryptomator/cryptofs/dir/C9rDecryptorTest.java +++ b/src/test/java/org/cryptomator/cryptofs/dir/C9rDecryptorTest.java @@ -1,5 +1,6 @@ package org.cryptomator.cryptofs.dir; +import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileNameCryptor; @@ -38,7 +39,7 @@ public void setup() { "aaaaBBBBccccDDDDeeeeFFFFggggHH==", }) public void testValidBase64Pattern(String input) { - Assertions.assertTrue(C9rDecryptor.BASE64_PATTERN.matcher(input).matches()); + Assertions.assertTrue(Constants.BASE64_PATTERN.matcher(input).matches()); } @ParameterizedTest @@ -53,7 +54,7 @@ public void testValidBase64Pattern(String input) { "aaaaBBBBccccDDDDeeeeFFFF conflict", // only a partial match }) public void testInvalidBase64Pattern(String input) { - Assertions.assertFalse(C9rDecryptor.BASE64_PATTERN.matcher(input).matches()); + Assertions.assertFalse(Constants.BASE64_PATTERN.matcher(input).matches()); } @Test diff --git a/src/test/java/org/cryptomator/cryptofs/dir/C9uConflictResolverTest.java b/src/test/java/org/cryptomator/cryptofs/dir/C9uConflictResolverTest.java new file mode 100644 index 00000000..59dcb979 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/dir/C9uConflictResolverTest.java @@ -0,0 +1,52 @@ +package org.cryptomator.cryptofs.dir; + +import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.inuse.InUseManager; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.mockito.Mockito.mock; + +public class C9uConflictResolverTest { + + C9uConflictResolver c9uConflictResolver; + + @BeforeEach + void beforeEach() { + var inUseManager = mock(InUseManager.class); + c9uConflictResolver = new C9uConflictResolver(inUseManager); + } + + @Test + @DisplayName("If filename is valid base64, the file exists") + void validBase64KeepsExisting(@TempDir Path tmpDir) throws IOException { + var ciphertextPath = tmpDir.resolve("aaaaBBBBccccDDDDeeeeFFFFggggHH=="+ Constants.INUSE_FILE_SUFFIX); + Files.createFile(ciphertextPath); + var node = new Node(ciphertextPath); + + var result = c9uConflictResolver.process(node); + + Assertions.assertEquals(0, result.count()); + Assertions.assertTrue(Files.exists(ciphertextPath)); + } + + @Test + @DisplayName("If filename is NOT valid base64, the file is deleted") + void InvalidBase64Deleted(@TempDir Path tmpDir) throws IOException { + var ciphertextPath = tmpDir.resolve("aaaaBBBBccccDDDDeeeeFFFFggggHH== (conflicted copy)"+ Constants.INUSE_FILE_SUFFIX); + Files.createFile(ciphertextPath); + var node = new Node(ciphertextPath); + + var result = c9uConflictResolver.process(node); + + Assertions.assertEquals(0, result.count()); + Assertions.assertTrue(Files.notExists(ciphertextPath)); + } +} From fb7ccb9d5153bd5aa7914bc4bfa4f45660360584 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 29 Oct 2025 11:24:00 +0100 Subject: [PATCH 90/95] Use lastModified timestamp to check if a in-use file was stolen/moved --- .../cryptofs/inuse/RealUseToken.java | 28 +++++++++++++++++-- .../cryptofs/inuse/RealUseTokenTest.java | 26 +++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 434d4510..9f231ca8 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -55,6 +55,7 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor private volatile Path filePath; private volatile FileChannel channel; private volatile boolean closed; + private volatile long lastModified; RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor, OpenOption openMode) { var delayedExecutor = CompletableFuture.delayedExecutor(Constants.INUSE_DELAY_MILLIS, TimeUnit.MILLISECONDS, tokenPersistor); @@ -96,9 +97,18 @@ void refresh() { if (closed || channel == null) { return; } + var currentLastModfied = Files.getLastModifiedTime(filePath).toMillis(); + if (currentLastModfied != lastModified) { + throw new ModifiedFileException(); //someone edited _our_ file. + } writeInUseFile(); + } catch (ModifiedFileException e) { + //TODO: event? we have no access to the cleartext! + LOG.debug("Failed to refresh in-use file {}.", filePath, e); + close(false); } catch (IOException e) { LOG.debug("Failed to refresh in-use file {}.", filePath, e); + close(); } finally { fileCreationSync.unlock(); } @@ -116,7 +126,8 @@ int writeInUseFile() throws IOException { prop.store(rawInfo, null); bytesWritten = encChannel.write(ByteBuffer.wrap(rawInfo.toByteArray())); } - channel.force(false); + channel.force(true); + lastModified = Files.getLastModifiedTime(filePath).toMillis(); return bytesWritten; } @@ -136,6 +147,7 @@ void moveToInternal(Path newFilePath) { useTokens.compute(newFilePath, (_, _) -> { try { if (channel != null) { + //TODO: does this affect the lastModified file? Files.move(filePath, newFilePath, StandardCopyOption.REPLACE_EXISTING); } return this; @@ -160,6 +172,10 @@ public boolean isClosed() { @Override public void close() { + close(true); + } + + void close(boolean deleteFile) { fileCreationSync.lock(); try { if (closed) { @@ -171,7 +187,9 @@ public void close() { if (channel != null) { try { channel.close(); - Files.deleteIfExists(filePath); + if(deleteFile) { + Files.deleteIfExists(filePath); + } } catch (IOException e) { //ignore LOG.warn("Failed to delete inUse File {}. Must be deleted manually.", path); @@ -184,6 +202,8 @@ public void close() { } } + //--- glue code --- + interface EncryptionDecorator { WritableByteChannel wrapWithEncryption(ByteChannel ch, Cryptor c); @@ -211,4 +231,8 @@ public int read(ByteBuffer dst) throws IOException { return delegate.read(dst); } } + + static class ModifiedFileException extends RuntimeException { + + } } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index bfac7078..ada8c6fd 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -8,16 +8,21 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchService; +import java.nio.file.attribute.FileTime; import java.time.Duration; import java.time.Instant; import java.util.Properties; @@ -80,6 +85,7 @@ public void testFileCreation() { Assertions.assertTrue(Files.notExists(filePath)); } + //TODO: test is flaky. Why? @Test @DisplayName("The properties file contains required keys with valid content") public void testFileContent() throws IOException { @@ -244,6 +250,26 @@ public void testFileRefresh() throws IOException { } } + @RepeatedTest(value = 5, failureThreshold = 1) + @Execution(ExecutionMode.SAME_THREAD) + @DisplayName("Refreshing a token with not-matching last-modified date closes token, but does not delete file ") + public void testFileRefreshWrongLastModified() throws IOException { + var filePath = tmpDir.resolve("inUse.file"); + + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); + Awaitility.await().pollDelay(Duration.ofMillis(10)).until(() -> true); + + Files.setLastModifiedTime(filePath, FileTime.from(Instant.ofEpochMilli(0))); + + token.refresh(); + + Assertions.assertTrue(token.isClosed()); + Assertions.assertTrue(Files.exists(filePath)); + } + Assertions.assertTrue(Files.exists(filePath)); + } + @Test @DisplayName("Before token persisting, refreshing a token does nothing") public void testFileRefreshSkip() throws IOException { From a5588876e230bd9f846cb3f16bf8f3a0026985f8 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 29 Oct 2025 17:38:34 +0100 Subject: [PATCH 91/95] Implement exponential backoff for token refresh with cap at 5min --- .../cryptofs/common/Constants.java | 1 - .../cryptofs/inuse/RealInUseManager.java | 21 ++------ .../cryptofs/inuse/RealUseToken.java | 54 ++++++++++++++++--- .../cryptomator/cryptofs/inuse/UseToken.java | 1 + .../cryptofs/inuse/RealInUseManagerTest.java | 16 +++--- .../cryptofs/inuse/RealUseTokenTest.java | 41 ++++++-------- 6 files changed, 76 insertions(+), 58 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 06a613d7..236d7eee 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -38,7 +38,6 @@ private Constants() { public static final String SEPARATOR = "/"; public static final String RECOVERY_DIR_NAME = "LOST+FOUND"; - public static final int INUSE_DELAY_MILLIS = 5000; public static final int INUSE_CLEARTEXT_SIZE = 1000; //calculation: Create inUse properties with owner consisting of \u2741.repeat(100) and encode it. Plus an additional buffer for future entries. public static final Pattern BASE64_PATTERN = Pattern.compile("[a-zA-Z0-9-_]{20}(?:[a-zA-Z0-9-_]{4})*(?:[a-zA-Z0-9-_]{4}|[a-zA-Z0-9-_]{3}=|[a-zA-Z0-9-_]{2}==)"); diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index c9454146..09ac5f69 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -27,26 +27,22 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; /** * Real implementation of {@link InUseManager}. *

    - * All {@value REFRESH_DELAY_MINUTES} minutes all open UseTokens (aka in-use-files) are rewritten with "lastUpdated" set to the current time. * To reduce reads from disk, this class implements a short-lived (5s) cache of the in-use-files. * If a file is ignored via {@link #ignoreInUse(Path)}, the ignore status is kept for only 2 minutes. */ public class RealInUseManager implements InUseManager { private static final Logger LOG = LoggerFactory.getLogger(RealInUseManager.class); - private static final int REFRESH_DELAY_MINUTES = 5; private final ConcurrentHashMap useTokens; private final Cache useInfoCache; private final Cache ignoredInUseFiles; private final ExecutorService tokenPersistor; - private final ScheduledExecutorService tokenRefresher; private final String owner; private final Cryptor cryptor; @@ -64,11 +60,6 @@ public RealInUseManager(String owner, Cryptor cryptor) { .maximumSize(1000) // .build(); this.tokenPersistor = Executors.newVirtualThreadPerTaskExecutor(); - this.tokenRefresher = Executors.newSingleThreadScheduledExecutor(); - tokenRefresher.scheduleWithFixedDelay(() -> useTokens.forEachValue(10L, RealUseToken::refresh), // - REFRESH_DELAY_MINUTES, // - REFRESH_DELAY_MINUTES, // - TimeUnit.MINUTES); } @@ -90,7 +81,7 @@ public boolean isInUseByOthers(Path ciphertextPath) { * Reads the in-use-file at the given path, validates it and checks if *

      *
    • this in-use-file belongs to the running crypto filesystem and
    • - *
    • the last update time is at most 2*{@value #REFRESH_DELAY_MINUTES}
    • minutes ago + *
    • the last update time is at most {@value UseToken#STALE_THRESHOLD_MINUTES} minutes ago *
    • the in-use-file is currently not ignored
    • *
    . * @@ -156,7 +147,7 @@ boolean isInUse(UseInfo useInfo) { } var timeSinceLastUpdate = Duration.between(useInfo.lastUpdated(), Instant.now()); - var threshold = Duration.of(2L * REFRESH_DELAY_MINUTES, ChronoUnit.MINUTES); + var threshold = Duration.of(UseToken.STALE_THRESHOLD_MINUTES, ChronoUnit.MINUTES); return timeSinceLastUpdate.compareTo(threshold) < 0; } @@ -215,18 +206,13 @@ public void ignoreInUse(Path ciphertextPath) { @Override public void close() throws IOException { - tokenRefresher.shutdown(); tokenPersistor.shutdown(); try { - if (!tokenRefresher.awaitTermination(5, TimeUnit.SECONDS)) { - tokenRefresher.shutdownNow(); - } if (!tokenPersistor.awaitTermination(5, TimeUnit.SECONDS)) { tokenPersistor.shutdownNow(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - tokenRefresher.shutdownNow(); tokenPersistor.shutdownNow(); } @@ -243,13 +229,12 @@ static Path computeInUseFilePath(Path p) { } //for testing - RealInUseManager(String owner, Cryptor cryptor, ConcurrentHashMap useTokens, Cache ignoredInUseFiles, Cache useInfoCache, ExecutorService tokenPersistor, ScheduledExecutorService tokenRefresher) { + RealInUseManager(String owner, Cryptor cryptor, ConcurrentHashMap useTokens, Cache ignoredInUseFiles, Cache useInfoCache, ExecutorService tokenPersistor) { this.owner = owner; this.cryptor = cryptor; this.useTokens = useTokens; this.ignoredInUseFiles = ignoredInUseFiles; this.useInfoCache = useInfoCache; this.tokenPersistor = tokenPersistor; - this.tokenRefresher = tokenRefresher; } } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index 9f231ca8..dbf7baae 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -24,13 +24,16 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Executor; +import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * Class to represent a file is "in use" by this filesystem. *

    - * The actual persistence of the "in use"-state with a file is delayed by {@value Constants#INUSE_DELAY_MILLIS} milliseconds. + * The actual persistence of the "in use"-state with a file is delayed by {@value CREATION_DELAY_MILLIS} milliseconds. + * The file is rewritten with lastUpdated set to the current time regularly based on an exponential backoff strategy capped at half the stale token time of {@value UseToken#STALE_THRESHOLD_MINUTES} minutes. * If the token is closed before it is persisted with a file, writing it to disk is skipped. */ public final class RealUseToken implements UseToken { @@ -44,9 +47,13 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor } private static final Logger LOG = LoggerFactory.getLogger(RealUseToken.class); + private static final Semaphore CONCURRENT_WRITES_SEMAPHORE = new Semaphore(20); + private static final int CREATION_DELAY_MILLIS = 5000; + private static final int MAX_REFRESH_DELAY_SECONDS = 300; private final String owner; - private final CompletableFuture creationTask; + private final AtomicReference> tokenPersistenceTask = new AtomicReference<>(); + private final Executor tokenPersistor; private final Cryptor cryptor; private final ConcurrentMap useTokens; private final EncryptionDecorator encWrapper; //this exists to make the class testable @@ -58,20 +65,53 @@ public static RealUseToken createWithExistingFile(Path p, String owner, Cryptor private volatile long lastModified; RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor, OpenOption openMode) { - var delayedExecutor = CompletableFuture.delayedExecutor(Constants.INUSE_DELAY_MILLIS, TimeUnit.MILLISECONDS, tokenPersistor); - this(filePath, owner, cryptor, useTokens, delayedExecutor, openMode, EncryptedChannels::wrapEncryptionAround); + this(filePath, owner, cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, openMode, EncryptedChannels::wrapEncryptionAround); } - RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor, OpenOption openMode, EncryptionDecorator encWrapper) { + RealUseToken(Path filePath, String owner, Cryptor cryptor, ConcurrentMap useTokens, Executor tokenPersistor, int creationDelayMillis, OpenOption openMode, EncryptionDecorator encWrapper) { this.owner = owner; this.filePath = filePath; this.cryptor = cryptor; this.useTokens = useTokens; this.encWrapper = encWrapper; this.closed = false; + this.tokenPersistor = tokenPersistor; + var openOptions = Set.of(StandardOpenOption.WRITE, openMode); - this.creationTask = CompletableFuture.runAsync(() -> createInUseFile(openOptions), tokenPersistor); + var delayedExecutor = CompletableFuture.delayedExecutor(creationDelayMillis, TimeUnit.MILLISECONDS, tokenPersistor); + var creationTask = CompletableFuture.runAsync(() -> createInUseFile(openOptions), delayedExecutor); + this.tokenPersistenceTask.set(creationTask); + scheduleRefresh(0); + } + + //TODO test? + private void scheduleRefresh(int count) { + var currentTask = tokenPersistenceTask.get(); + if (closed || currentTask.isCancelled()) { + return; + } + + var delayedExecutor = delayExponentiallyWithCap(tokenPersistor, count); + var nextPersistenceTask = currentTask.thenRunAsync(() -> { + try { + CONCURRENT_WRITES_SEMAPHORE.acquire(); + refresh(); + CONCURRENT_WRITES_SEMAPHORE.release(); + scheduleRefresh(count + 1); + } catch (InterruptedException e) { + LOG.debug("Interrupt during refresh of {}. Closing token.", filePath); + close(); + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + }, delayedExecutor); + tokenPersistenceTask.set(nextPersistenceTask); + } + private Executor delayExponentiallyWithCap(Executor executor, int count) { + //0:15s, 1:30s, 2:60s=1min, 3:120s=2min, 4:240s=4min, else:300s=5min + var delay = count > 4 ? MAX_REFRESH_DELAY_SECONDS : 15 * Math.powExact(2, count); + return CompletableFuture.delayedExecutor(delay, TimeUnit.SECONDS, executor); } private void createInUseFile(Set openOptions) { @@ -182,7 +222,7 @@ void close(boolean deleteFile) { return; } closed = true; - creationTask.cancel(false); + tokenPersistenceTask.get().cancel(false); useTokens.compute(filePath, (path, _) -> { if (channel != null) { try { diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java index 4eb1f9d9..15d97479 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java @@ -30,4 +30,5 @@ public boolean isClosed() { //fields String LASTUPDATED_KEY = "lastUpdated"; String OWNER_KEY = "owner"; + int STALE_THRESHOLD_MINUTES = 10; } diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java index 8baa760b..d83e1020 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealInUseManagerTest.java @@ -58,7 +58,7 @@ public void testUseByOthersWithExistingToken() throws IOException { preparedMap.put(inUseFilePath, mock(RealUseToken.class)); var filesMarkedForStealing = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing, useInfoCache, tokenPersistor, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, filesMarkedForStealing, useInfoCache, tokenPersistor); var inUseSpy = spy(inUseManager); var result = inUseSpy.isInUseByOthers(ciphertextPath); @@ -98,7 +98,7 @@ public void testUsePutsPathInMap() throws FileAlreadyInUseException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -143,7 +143,7 @@ public void testCreateExistingValid() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredInUseFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -164,7 +164,7 @@ public void testCreateExistingInvalid() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -185,7 +185,7 @@ public void testCreateNotExisting() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor); var inUseSpy = spy(inUseManager); var token = mock(RealUseToken.class); @@ -206,7 +206,7 @@ public void testCreateFailedRead() throws IOException { var preparedMap = new ConcurrentHashMap(); var ignoredFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredFiles, useInfoCache, tokenPersistor); var inUseSpy = spy(inUseManager); doReturn(true).when(inUseSpy).isInUse(inUseFilePath); @@ -337,7 +337,7 @@ void isInUseChecksIgnoredCache() throws IOException { var ignoredInUseFiles = mock(Cache.class); var useInfoCache = mock(Cache.class); doReturn(Boolean.TRUE).when(ignoredInUseFiles).getIfPresent(inUseFilePath); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor); var result = inUseManager.isInUse(inUseFilePath); @@ -354,7 +354,7 @@ void isInUseChecksUseInfoCache() throws IOException { var useInfoCache = mock(Cache.class); var useInfo = new UseInfo("bob", Instant.now()); doReturn(useInfo).when(useInfoCache).get(eq(inUseFilePath), any()); - var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor, null); + var inUseManager = new RealInUseManager("cryptobot3000", cryptor, preparedMap, ignoredInUseFiles, useInfoCache, tokenPersistor); var result = inUseManager.isInUse(inUseFilePath); diff --git a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java index ada8c6fd..b739044e 100644 --- a/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java +++ b/src/test/java/org/cryptomator/cryptofs/inuse/RealUseTokenTest.java @@ -11,12 +11,9 @@ import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.api.parallel.Execution; -import org.junit.jupiter.api.parallel.ExecutionMode; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; @@ -26,12 +23,10 @@ import java.time.Duration; import java.time.Instant; import java.util.Properties; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Executor; import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -49,8 +44,8 @@ public class RealUseTokenTest { @TempDir Path tmpDir; private WatchService watchService; - private static final long FILE_OPERATION_DELAY_MILLIS = 1000L; - private static final Duration FILE_OPERATION_DELAY = Duration.ofMillis(FILE_OPERATION_DELAY_MILLIS-100L); //allow some leeway + private static final int CREATION_DELAY_MILLIS = 1000; + private static final Duration FILE_OPERATION_DELAY = Duration.ofMillis(CREATION_DELAY_MILLIS -100L); //allow some leeway private static final Duration FILE_OPERATION_MAX = FILE_OPERATION_DELAY.plusMillis(2000L); @BeforeEach @@ -59,8 +54,7 @@ public void beforeEach() throws IOException { encWrapper = mock(RealUseToken.EncryptionDecorator.class); useTokens = new ConcurrentHashMap<>(); watchService = tmpDir.getFileSystem().newWatchService(); - tokenPersistor = CompletableFuture.delayedExecutor(FILE_OPERATION_DELAY_MILLIS, TimeUnit.MILLISECONDS); - + tokenPersistor = Executors.newVirtualThreadPerTaskExecutor(); doAnswer(invocation -> invocation.getArgument(0)) //just return the real file channel .when(encWrapper).wrapWithEncryption(any(), eq(cryptor)); } @@ -78,20 +72,20 @@ public void afterEach() { @DisplayName("After X seconds of token creation, a new file is created") public void testFileCreation() { var filePath = tmpDir.resolve("inUse.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); Assertions.assertTrue(Files.exists(filePath)); } Assertions.assertTrue(Files.notExists(filePath)); } - //TODO: test is flaky. Why? - @Test + @RepeatedTest(20) @DisplayName("The properties file contains required keys with valid content") public void testFileContent() throws IOException { var filePath = tmpDir.resolve("inUse.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); + Awaitility.await().pollDelay(Duration.ofMillis(10)).until(() -> true); var props = new Properties(); var rawProps = Files.readAllBytes(filePath); @@ -109,7 +103,7 @@ public void testFileSteal() throws IOException { var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); var fileTime = Files.getLastModifiedTime(filePath); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> fileTime.compareTo(Files.getLastModifiedTime(filePath)) < 0); var events = watchKey.pollEvents(); var createEvent = events.stream().filter(e -> e.kind().equals(StandardWatchEventKinds.ENTRY_MODIFY)).findAny(); @@ -128,7 +122,7 @@ public void testFileStealFails() throws IOException { var filePath = tmpDir.resolve("inUse.file"); //file does not exist var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.TRUNCATE_EXISTING, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(token::isClosed); Assertions.assertTrue(Files.notExists(filePath)); Assertions.assertTrue(token.isClosed()); @@ -143,7 +137,7 @@ public void testTokenCloseBeforeFileOperation() throws IOException { var filePath = tmpDir.resolve("inUse.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { Assertions.assertTrue(Files.notExists(filePath)); } Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); @@ -159,7 +153,7 @@ public void testMoveToBefore() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { token.moveToInternal(targetPath); //no file operation after move @@ -189,7 +183,7 @@ public void testMoveToAfter() { var filePath = tmpDir.resolve("inUseMove.file"); var targetPath = tmpDir.resolve("inUseMove2.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); token.moveToInternal(targetPath); @@ -212,7 +206,7 @@ public void testMoveToClosed() throws IOException { var targetPath = tmpDir.resolve("inUse2.file"); var watchKey = tmpDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { token.close(); Awaitility.await().pollDelay(FILE_OPERATION_MAX).timeout(FILE_OPERATION_MAX.multipliedBy(2)).until(() -> true); @@ -230,7 +224,7 @@ public void testMoveToClosed() throws IOException { public void testFileRefresh() throws IOException { var filePath = tmpDir.resolve("inUse.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); var props = new Properties(); @@ -250,13 +244,12 @@ public void testFileRefresh() throws IOException { } } - @RepeatedTest(value = 5, failureThreshold = 1) - @Execution(ExecutionMode.SAME_THREAD) + @Test @DisplayName("Refreshing a token with not-matching last-modified date closes token, but does not delete file ") public void testFileRefreshWrongLastModified() throws IOException { var filePath = tmpDir.resolve("inUse.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { Awaitility.await().atLeast(FILE_OPERATION_DELAY).atMost(FILE_OPERATION_MAX).until(() -> Files.exists(filePath)); Awaitility.await().pollDelay(Duration.ofMillis(10)).until(() -> true); @@ -275,7 +268,7 @@ public void testFileRefreshWrongLastModified() throws IOException { public void testFileRefreshSkip() throws IOException { var filePath = tmpDir.resolve("inUse.file"); - try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, StandardOpenOption.CREATE_NEW, encWrapper)) { + try (var token = new RealUseToken(filePath, "test3000", cryptor, useTokens, tokenPersistor, CREATION_DELAY_MILLIS, StandardOpenOption.CREATE_NEW, encWrapper)) { token.refresh(); verify(encWrapper, never()).wrapWithEncryption(any(), eq(cryptor)); } From 75b3b50e01bba5b9d4286a6ac7961ae0e4bc0282 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 29 Oct 2025 17:49:40 +0100 Subject: [PATCH 92/95] clean up --- src/main/java/org/cryptomator/cryptofs/common/Constants.java | 2 +- .../org/cryptomator/cryptofs/inuse/RealInUseManager.java | 5 +++-- .../java/org/cryptomator/cryptofs/inuse/RealUseToken.java | 4 ++-- src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 236d7eee..f1ce342e 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -38,7 +38,7 @@ private Constants() { public static final String SEPARATOR = "/"; public static final String RECOVERY_DIR_NAME = "LOST+FOUND"; - public static final int INUSE_CLEARTEXT_SIZE = 1000; //calculation: Create inUse properties with owner consisting of \u2741.repeat(100) and encode it. Plus an additional buffer for future entries. + public static final int INUSE_CLEARTEXT_SIZE = 1000; public static final Pattern BASE64_PATTERN = Pattern.compile("[a-zA-Z0-9-_]{20}(?:[a-zA-Z0-9-_]{4})*(?:[a-zA-Z0-9-_]{4}|[a-zA-Z0-9-_]{3}=|[a-zA-Z0-9-_]{2}==)"); } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java index 09ac5f69..3b0d6fde 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealInUseManager.java @@ -104,7 +104,7 @@ boolean isInUse(Path inUseFilePath) throws IOException, IllegalArgumentException } Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgumentException { - var bytes = ByteBuffer.allocate(Constants.INUSE_CLEARTEXT_SIZE); + var bytes = ByteBuffer.allocate(UseToken.MAX_CLEARTEXT_SIZE_BYTES); final int readBytes; try (var ch = Files.newByteChannel(inUseFilePath, StandardOpenOption.READ); // var channel = EncryptedChannels.wrapDecryptionAround(ch, cryptor)) { @@ -112,7 +112,7 @@ Properties readInUseFile(Path inUseFilePath) throws IOException, IllegalArgument } if (readBytes < 0) { - throw new IllegalArgumentException("Empty cleartext inUse file"); + throw new IllegalArgumentException("inUse file has no cleartext content"); } var props = new Properties(); @@ -201,6 +201,7 @@ RealUseToken createInternal(Path inUseFilePath) throws UncheckedIOException { @Override public void ignoreInUse(Path ciphertextPath) { var inUseFilePath = computeInUseFilePath(ciphertextPath); + LOG.info("Ignoring in-use-file for {}", inUseFilePath); ignoredInUseFiles.put(inUseFilePath, Boolean.TRUE); } diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java index dbf7baae..2dbc517f 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/RealUseToken.java @@ -159,7 +159,7 @@ int writeInUseFile() throws IOException { final int bytesWritten; try (var nonClosingWrapper = new NonClosingByteChannel(channel); // var encChannel = encWrapper.wrapWithEncryption(nonClosingWrapper, cryptor)) { - var rawInfo = new ByteArrayOutputStream(Constants.INUSE_CLEARTEXT_SIZE); + var rawInfo = new ByteArrayOutputStream(UseToken.MAX_CLEARTEXT_SIZE_BYTES); var prop = new Properties(); prop.put(UseToken.OWNER_KEY, owner); prop.put(UseToken.LASTUPDATED_KEY, Instant.now().toString()); @@ -227,7 +227,7 @@ void close(boolean deleteFile) { if (channel != null) { try { channel.close(); - if(deleteFile) { + if (deleteFile) { Files.deleteIfExists(filePath); } } catch (IOException e) { diff --git a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java index 15d97479..0956f6b0 100644 --- a/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java +++ b/src/main/java/org/cryptomator/cryptofs/inuse/UseToken.java @@ -31,4 +31,5 @@ public boolean isClosed() { String LASTUPDATED_KEY = "lastUpdated"; String OWNER_KEY = "owner"; int STALE_THRESHOLD_MINUTES = 10; + int MAX_CLEARTEXT_SIZE_BYTES = 1000; //calculation: Create inUse properties with owner consisting of \u2741.repeat(100) symbols and encode it. Plus an additional buffer for future entries. } From fc0ac814612122f2fcec7fa083a92e9a38d58eaf Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 29 Oct 2025 17:54:05 +0100 Subject: [PATCH 93/95] fix compilation failure in tests --- .../cryptofs/CryptoFileChannelWriteReadIntegrationTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java index 02e689a8..0812cb03 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileChannelWriteReadIntegrationTest.java @@ -178,8 +178,8 @@ public void afterAll() throws IOException { @DisplayName("Opening a file channel creates an in-use file and removes it on close") public void testOpeningFCCreatesInUseFile() throws IOException { try (var writer = FileChannel.open(file, CREATE, WRITE)) { - Awaitility.await().atLeast(Constants.INUSE_DELAY_MILLIS - 100, TimeUnit.MILLISECONDS) // - .atMost(Constants.INUSE_DELAY_MILLIS + 3000, TimeUnit.MILLISECONDS) // + Awaitility.await().atLeast( 4900, TimeUnit.MILLISECONDS) // + .atMost(8000, TimeUnit.MILLISECONDS) // .until(() -> numberOfInUseFiles() == 1); } var numberAfterClose = numberOfInUseFiles(); From 415cb76db7546c1b2896ee7f013605f043a00bea Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Wed, 29 Oct 2025 17:57:53 +0100 Subject: [PATCH 94/95] cleanup --- .../org/cryptomator/cryptofs/dir/C9uConflictResolver.java | 7 ++----- .../java/org/cryptomator/cryptofs/dir/C9uProcessor.java | 2 +- .../cryptomator/cryptofs/dir/C9uConflictResolverTest.java | 3 +-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/dir/C9uConflictResolver.java b/src/main/java/org/cryptomator/cryptofs/dir/C9uConflictResolver.java index 3f5f5792..517531d0 100644 --- a/src/main/java/org/cryptomator/cryptofs/dir/C9uConflictResolver.java +++ b/src/main/java/org/cryptomator/cryptofs/dir/C9uConflictResolver.java @@ -23,17 +23,14 @@ public class C9uConflictResolver { private static final Logger LOG = LoggerFactory.getLogger(C9uConflictResolver.class); - private final InUseManager inUseManager; - @Inject - public C9uConflictResolver(InUseManager inUseManager) { - this.inUseManager = inUseManager; + public C9uConflictResolver() { } /** * Processes files with {@value Constants#INUSE_FILE_SUFFIX} file extension. (in-use files) *

    - * If the in-use file is not valid bas64 encoding, delete the file. + * If the in-use file is not valid base64 encoding, delete the file. * * @param node * @return an empty stream. diff --git a/src/main/java/org/cryptomator/cryptofs/dir/C9uProcessor.java b/src/main/java/org/cryptomator/cryptofs/dir/C9uProcessor.java index 29b402e6..84109d27 100644 --- a/src/main/java/org/cryptomator/cryptofs/dir/C9uProcessor.java +++ b/src/main/java/org/cryptomator/cryptofs/dir/C9uProcessor.java @@ -6,7 +6,7 @@ import java.util.stream.Stream; /** - * Processes in-use files (file extension {@value Constants#INUSE_FILE_SUFFIX}. + * Processes in-use files (file extension {@value Constants#INUSE_FILE_SUFFIX}). */ @DirectoryStreamScoped public class C9uProcessor { diff --git a/src/test/java/org/cryptomator/cryptofs/dir/C9uConflictResolverTest.java b/src/test/java/org/cryptomator/cryptofs/dir/C9uConflictResolverTest.java index 59dcb979..4c87218b 100644 --- a/src/test/java/org/cryptomator/cryptofs/dir/C9uConflictResolverTest.java +++ b/src/test/java/org/cryptomator/cryptofs/dir/C9uConflictResolverTest.java @@ -20,8 +20,7 @@ public class C9uConflictResolverTest { @BeforeEach void beforeEach() { - var inUseManager = mock(InUseManager.class); - c9uConflictResolver = new C9uConflictResolver(inUseManager); + c9uConflictResolver = new C9uConflictResolver(); } @Test From cd64dd00530e3fb9a973b3f172d262722c54e615 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 31 Oct 2025 14:49:42 +0100 Subject: [PATCH 95/95] Include c9u in allowed file extensions when listing encrypted directories --- .../cryptomator/cryptofs/dir/DirectoryStreamFactory.java | 5 ++++- .../cryptofs/dir/DirectoryStreamFactoryTest.java | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java b/src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java index 3acfc25b..6d27809b 100644 --- a/src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java +++ b/src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java @@ -15,10 +15,13 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.Map; +import java.util.Set; @CryptoFileSystemScoped public class DirectoryStreamFactory { + private static final Set FILE_EXTENSIONS = Set.of(Constants.CRYPTOMATOR_FILE_SUFFIX, Constants.DEFLATED_FILE_SUFFIX, Constants.INUSE_FILE_SUFFIX); + private final CryptoPathMapper cryptoPathMapper; private final DirectoryStreamComponent.Factory directoryStreamComponentFactory; private final Map> streams = new HashMap<>(); @@ -47,7 +50,7 @@ public synchronized CryptoDirectoryStream newDirectoryStream(CryptoPath cleartex boolean matchesEncryptedContentPattern(Path path) { var tmp = path.getFileName().toString(); return tmp.length() >= Constants.MIN_CIPHER_NAME_LENGTH // - && (tmp.endsWith(Constants.CRYPTOMATOR_FILE_SUFFIX) || tmp.endsWith(Constants.DEFLATED_FILE_SUFFIX)); + && FILE_EXTENSIONS.stream().anyMatch(tmp::endsWith); } public synchronized void close() throws IOException { diff --git a/src/test/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactoryTest.java b/src/test/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactoryTest.java index b9fce578..e269319a 100644 --- a/src/test/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactoryTest.java +++ b/src/test/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactoryTest.java @@ -104,7 +104,7 @@ public void testNewDirectoryStreamAfterClosedThrowsClosedFileSystemException() t }); } - @DisplayName("CiphertextDirStream only contains files with names at least 26 chars long and ending with .c9r or .c9s") + @DisplayName("CiphertextDirStream only contains files with names at least 26 chars long and ending with .c9r, .c9s or .c9u") @ParameterizedTest @MethodSource("provideFilterExamples") public void testCiphertextDirStreamFilter(String fileName, boolean expected) { @@ -119,10 +119,12 @@ public void testCiphertextDirStreamFilter(String fileName, boolean expected) { private static Stream provideFilterExamples() { return Stream.of( // + Arguments.of("b".repeat(Constants.MIN_CIPHER_NAME_LENGTH - 5)+".c9u", false), // Arguments.of("b".repeat(Constants.MIN_CIPHER_NAME_LENGTH - 5)+".c9r", false), // Arguments.of("b".repeat(Constants.MIN_CIPHER_NAME_LENGTH - 5)+".c9s", false), // Arguments.of("a".repeat(Constants.MIN_CIPHER_NAME_LENGTH - 4)+".c9r", true), // - Arguments.of("a".repeat(Constants.MIN_CIPHER_NAME_LENGTH - 4)+".c9s", true)); + Arguments.of("a".repeat(Constants.MIN_CIPHER_NAME_LENGTH - 4)+".c9s", true), // + Arguments.of("a".repeat(Constants.MIN_CIPHER_NAME_LENGTH - 4)+".c9u", true)); } }