Skip to content

Commit

Permalink
attempt to import conflict suffix from ciphertext
Browse files Browse the repository at this point in the history
fallback to numerical conflict suffixes only if name is already taken
  • Loading branch information
overheadhunter committed Feb 1, 2025
1 parent 55a5d9f commit bb4911b
Show file tree
Hide file tree
Showing 2 changed files with 59 additions and 26 deletions.
51 changes: 32 additions & 19 deletions src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public Stream<Node> process(Node node) {
Path canonicalPath = node.ciphertextPath.resolveSibling(canonicalCiphertextFileName);
return resolveConflict(node, canonicalPath);
} catch (IOException e) {
LOG.error("Failed to resolve conflict for " + node.ciphertextPath, e);
LOG.error("Failed to resolve conflict for {}", node.ciphertextPath, e);
return Stream.empty();
}
}
Expand All @@ -75,39 +75,52 @@ private Stream<Node> resolveConflict(Node conflicting, Path canonicalPath) throw
resolved.extractedCiphertext = conflicting.extractedCiphertext;
return Stream.of(resolved);
} else {
return Stream.of(renameConflictingFile(canonicalPath, conflictingPath, conflicting.cleartextName));
return Stream.of(renameConflictingFile(canonicalPath, conflicting));
}
}

/**
* Resolves a conflict by renaming the conflicting file.
*
* @param canonicalPath The path to the original (conflict-free) file.
* @param conflictingPath The path to the potentially conflicting file.
* @param cleartext The cleartext name of the conflicting file.
* @param conflicting The conflicting file.
* @return The newly created Node after renaming the conflicting file.
* @throws IOException
*/
private Node renameConflictingFile(Path canonicalPath, Path conflictingPath, String cleartext) throws IOException {
private Node renameConflictingFile(Path canonicalPath, Node conflicting) throws IOException {
assert Files.exists(canonicalPath);
final int beginOfFileExtension = cleartext.lastIndexOf('.');
final String fileExtension = (beginOfFileExtension > 0) ? cleartext.substring(beginOfFileExtension) : "";
final String basename = (beginOfFileExtension > 0) ? cleartext.substring(0, beginOfFileExtension) : cleartext;
final String lengthRestrictedBasename = basename.substring(0, Math.min(basename.length(), maxCleartextFileNameLength - fileExtension.length() - 5)); // 5 chars for conflict suffix " (42)"
String alternativeCleartext;
String alternativeCiphertext;
String alternativeCiphertextName;
Path alternativePath;
int i = 1;
do {
alternativeCleartext = lengthRestrictedBasename + " (" + i++ + ")" + fileExtension;
assert conflicting.fullCiphertextFileName.endsWith(Constants.CRYPTOMATOR_FILE_SUFFIX);
assert conflicting.fullCiphertextFileName.contains(conflicting.extractedCiphertext);

final String cleartext = conflicting.cleartextName;
final int beginOfCleartextExt = cleartext.lastIndexOf('.');
final String cleartextFileExt = (beginOfCleartextExt > 0) ? cleartext.substring(beginOfCleartextExt) : "";
final String cleartextBasename = (beginOfCleartextExt > 0) ? cleartext.substring(0, beginOfCleartextExt) : cleartext;

// let's assume that some the sync conflict string is added at the end of the file name, but before .c9r:
final int endOfCiphertext = conflicting.fullCiphertextFileName.indexOf(conflicting.extractedCiphertext) + conflicting.extractedCiphertext.length();
final String originalConflictSuffix = conflicting.fullCiphertextFileName.substring(endOfCiphertext, conflicting.fullCiphertextFileName.length() - Constants.CRYPTOMATOR_FILE_SUFFIX.length());

// split available maxCleartextFileNameLength between basename, conflict suffix, and file extension:
final int netCleartext = maxCleartextFileNameLength - cleartextFileExt.length(); // file extension must be preserved
final String conflictSuffix = originalConflictSuffix.substring(0, Math.min(originalConflictSuffix.length(), netCleartext / 2)); // max 50% of available space
final int conflictSuffixLen = Math.max(5, conflictSuffix.length()); // prefer to use original conflict suffix, but reserver at least 5 chars for numerical fallback: " (42)"
final String lengthRestrictedBasename = cleartextBasename.substring(0, Math.min(cleartextBasename.length(), netCleartext - conflictSuffixLen)); // remaining space for basename

String alternativeCleartext = lengthRestrictedBasename + conflictSuffix + cleartextFileExt;
String alternativeCiphertext = cryptor.fileNameCryptor().encryptFilename(BaseEncoding.base64Url(), alternativeCleartext, dirId);
String alternativeCiphertextName = alternativeCiphertext + Constants.CRYPTOMATOR_FILE_SUFFIX;
Path alternativePath = canonicalPath.resolveSibling(alternativeCiphertextName);
for (int i = 1; Files.exists(alternativePath); i++) {
alternativeCleartext = lengthRestrictedBasename + " (" + i++ + ")" + cleartextFileExt;
alternativeCiphertext = cryptor.fileNameCryptor().encryptFilename(BaseEncoding.base64Url(), alternativeCleartext, dirId);
alternativeCiphertextName = alternativeCiphertext + Constants.CRYPTOMATOR_FILE_SUFFIX;
alternativePath = canonicalPath.resolveSibling(alternativeCiphertextName);
} while (Files.exists(alternativePath));
}

assert alternativeCiphertextName.length() <= maxC9rFileNameLength;
LOG.info("Moving conflicting file {} to {}", conflictingPath, alternativePath);
Files.move(conflictingPath, alternativePath, StandardCopyOption.ATOMIC_MOVE);
LOG.info("Moving conflicting file {} to {}", conflicting.ciphertextPath, alternativePath);
Files.move(conflicting.ciphertextPath, alternativePath, StandardCopyOption.ATOMIC_MOVE);
Node node = new Node(alternativePath);
node.cleartextName = alternativeCleartext;
node.extractedCiphertext = alternativeCiphertext;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public void setup() {
fileNameCryptor = Mockito.mock(FileNameCryptor.class);
vaultConfig = Mockito.mock(VaultConfig.class);
Mockito.when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor);
Mockito.when(vaultConfig.getShorteningThreshold()).thenReturn(44); // results in max cleartext size = 14
Mockito.when(vaultConfig.getShorteningThreshold()).thenReturn(84); // results in max cleartext size = 44
conflictResolver = new C9rConflictResolver(cryptor, "foo", vaultConfig);
}

Expand Down Expand Up @@ -60,10 +60,10 @@ public void testResolveHiddenNode(String filename) {

@Test
public void testResolveConflictingFileByChoosingNewName(@TempDir Path dir) throws IOException {
Files.createFile(dir.resolve("foo (1).c9r"));
Files.createFile(dir.resolve("foo (Created by Alice).c9r"));
Files.createFile(dir.resolve("foo.c9r"));
Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn("baz");
Node unresolved = new Node(dir.resolve("foo (1).c9r"));
Node unresolved = new Node(dir.resolve("foo (Created by Alice).c9r"));
unresolved.cleartextName = "bar.txt";
unresolved.extractedCiphertext = "foo";

Expand All @@ -72,26 +72,46 @@ public void testResolveConflictingFileByChoosingNewName(@TempDir Path dir) throw

Assertions.assertNotEquals(unresolved, resolved);
Assertions.assertEquals("baz.c9r", resolved.fullCiphertextFileName);
Assertions.assertEquals("bar (Created by Alice).txt", resolved.cleartextName);
Assertions.assertTrue(Files.exists(resolved.ciphertextPath));
Assertions.assertFalse(Files.exists(unresolved.ciphertextPath));
}

@Test
public void testResolveConflictingFileByAddingNumericSuffix(@TempDir Path dir) throws IOException {
Files.createFile(dir.resolve("foo (Created by Alice).c9r"));
Files.createFile(dir.resolve("foo.c9r"));
Files.createFile(dir.resolve("baz.c9r")); // resolved name already occupied, try cux next!
Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn("baz").thenReturn("qux");
Node unresolved = new Node(dir.resolve("foo (Created by Alice).c9r"));
unresolved.cleartextName = "bar.txt";
unresolved.extractedCiphertext = "foo";

Stream<Node> result = conflictResolver.process(unresolved);
Node resolved = result.findAny().get();

Assertions.assertNotEquals(unresolved, resolved);
Assertions.assertEquals("qux.c9r", resolved.fullCiphertextFileName);
Assertions.assertEquals("bar (1).txt", resolved.cleartextName);
Assertions.assertTrue(Files.exists(resolved.ciphertextPath));
Assertions.assertFalse(Files.exists(unresolved.ciphertextPath));
}

@Test
public void testResolveConflictingFileByChoosingNewLengthLimitedName(@TempDir Path dir) throws IOException {
Files.createFile(dir.resolve("foo (1).c9r"));
Files.createFile(dir.resolve("foo (Created by Alice on 2024-01-31).c9r"));
Files.createFile(dir.resolve("foo.c9r"));
Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn("baz");
Node unresolved = new Node(dir.resolve("foo (1).c9r"));
unresolved.cleartextName = "hello world.txt";
Node unresolved = new Node(dir.resolve("foo (Created by Alice on 2024-01-31).c9r"));
unresolved.cleartextName = "this is a rather long file name.txt";
unresolved.extractedCiphertext = "foo";

Stream<Node> result = conflictResolver.process(unresolved);
Node resolved = result.findAny().get();

Assertions.assertNotEquals(unresolved, resolved);
Assertions.assertEquals("baz.c9r", resolved.fullCiphertextFileName);
Assertions.assertEquals("hello (1).txt", resolved.cleartextName);
Assertions.assertEquals("this is a rather lon (Created by Alice o.txt", resolved.cleartextName);
Assertions.assertTrue(Files.exists(resolved.ciphertextPath));
Assertions.assertFalse(Files.exists(unresolved.ciphertextPath));
}
Expand Down

0 comments on commit bb4911b

Please sign in to comment.