diff --git a/main/crypto-layer/pom.xml b/main/crypto-layer/pom.xml
index f0514bb3b..ca0a54e35 100644
--- a/main/crypto-layer/pom.xml
+++ b/main/crypto-layer/pom.xml
@@ -27,6 +27,10 @@
org.cryptomator
filesystem-api
+
+ org.cryptomator
+ shortening-layer
+
diff --git a/main/crypto-layer/src/main/java/org/cryptomator/crypto/engine/impl/CryptorImpl.java b/main/crypto-layer/src/main/java/org/cryptomator/crypto/engine/impl/CryptorImpl.java
index fb62c6cf2..330d5ce39 100644
--- a/main/crypto-layer/src/main/java/org/cryptomator/crypto/engine/impl/CryptorImpl.java
+++ b/main/crypto-layer/src/main/java/org/cryptomator/crypto/engine/impl/CryptorImpl.java
@@ -46,17 +46,18 @@ public class CryptorImpl implements Cryptor {
@Override
public FilenameCryptor getFilenameCryptor() {
// lazy initialization pattern as proposed here http://stackoverflow.com/a/30247202/4014509
- FilenameCryptor cryptor = filenameCryptor.get();
- if (cryptor == null) {
- cryptor = new FilenameCryptorImpl(encryptionKey, macKey);
- if (filenameCryptor.compareAndSet(null, cryptor)) {
- return cryptor;
+ final FilenameCryptor existingCryptor = filenameCryptor.get();
+ if (existingCryptor != null) {
+ return existingCryptor;
+ } else {
+ final FilenameCryptorImpl newCryptor = new FilenameCryptorImpl(encryptionKey, macKey);
+ if (filenameCryptor.compareAndSet(null, newCryptor)) {
+ return newCryptor;
} else {
// CAS failed: other thread set an object
+ newCryptor.destroy();
return filenameCryptor.get();
}
- } else {
- return cryptor;
}
}
diff --git a/main/crypto-layer/src/main/java/org/cryptomator/crypto/engine/impl/FilenameCryptorImpl.java b/main/crypto-layer/src/main/java/org/cryptomator/crypto/engine/impl/FilenameCryptorImpl.java
index ea1dfe96d..508f39f27 100644
--- a/main/crypto-layer/src/main/java/org/cryptomator/crypto/engine/impl/FilenameCryptorImpl.java
+++ b/main/crypto-layer/src/main/java/org/cryptomator/crypto/engine/impl/FilenameCryptorImpl.java
@@ -15,7 +15,6 @@ import java.security.NoSuchAlgorithmException;
import javax.crypto.AEADBadTagException;
import javax.crypto.SecretKey;
-import javax.security.auth.DestroyFailedException;
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.BaseNCodec;
@@ -26,7 +25,7 @@ import org.cryptomator.siv.SivMode;
class FilenameCryptorImpl implements FilenameCryptor {
private static final BaseNCodec BASE32 = new Base32();
- private static final ThreadLocal SHA256 = new ThreadLocalSha256();
+ private static final ThreadLocal SHA1 = new ThreadLocalSha1();
private static final SivMode AES_SIV = new SivMode();
private final SecretKey encryptionKey;
@@ -44,7 +43,7 @@ class FilenameCryptorImpl implements FilenameCryptor {
public String hashDirectoryId(String cleartextDirectoryId) {
final byte[] cleartextBytes = cleartextDirectoryId.getBytes(StandardCharsets.UTF_8);
byte[] encryptedBytes = AES_SIV.encrypt(encryptionKey, macKey, cleartextBytes);
- final byte[] hashedBytes = SHA256.get().digest(encryptedBytes);
+ final byte[] hashedBytes = SHA1.get().digest(encryptedBytes);
return BASE32.encodeAsString(hashedBytes);
}
@@ -66,14 +65,14 @@ class FilenameCryptorImpl implements FilenameCryptor {
}
}
- private static class ThreadLocalSha256 extends ThreadLocal {
+ private static class ThreadLocalSha1 extends ThreadLocal {
@Override
protected MessageDigest initialValue() {
try {
- return MessageDigest.getInstance("SHA-256");
+ return MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
- throw new AssertionError("SHA-256 exists in every JVM");
+ throw new AssertionError("SHA-1 exists in every JVM");
}
}
@@ -88,7 +87,7 @@ class FilenameCryptorImpl implements FilenameCryptor {
/* ======================= destruction ======================= */
@Override
- public void destroy() throws DestroyFailedException {
+ public void destroy() {
TheDestroyer.destroyQuietly(encryptionKey);
TheDestroyer.destroyQuietly(macKey);
}
diff --git a/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFile.java b/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFile.java
index b79691c13..37d0267ba 100644
--- a/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFile.java
+++ b/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFile.java
@@ -26,9 +26,8 @@ public class CryptoFile extends CryptoNode implements File {
super(parent, name, cryptor);
}
- @Override
String encryptedName() {
- return name() + FILE_EXT;
+ return cryptor.getFilenameCryptor().encryptFilename(name()) + FILE_EXT;
}
@Override
diff --git a/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFileSystem.java b/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFileSystem.java
index bf44affde..e2471c91e 100644
--- a/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFileSystem.java
+++ b/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFileSystem.java
@@ -29,7 +29,6 @@ public class CryptoFileSystem extends CryptoFolder implements FileSystem {
private static final Logger LOG = LoggerFactory.getLogger(CryptoFileSystem.class);
private static final String DATA_ROOT_DIR = "d";
- private static final String METADATA_ROOT_DIR = "m";
private static final String ROOT_DIR_FILE = "root";
private static final String MASTERKEY_FILENAME = "masterkey.cryptomator";
private static final String MASTERKEY_BACKUP_FILENAME = "masterkey.cryptomator.bkup";
@@ -96,11 +95,6 @@ public class CryptoFileSystem extends CryptoFolder implements FileSystem {
return physicalRoot.folder(DATA_ROOT_DIR);
}
- @Override
- Folder physicalMetadataRoot() {
- return physicalRoot.folder(METADATA_ROOT_DIR);
- }
-
@Override
public Optional parent() {
return Optional.empty();
@@ -119,7 +113,6 @@ public class CryptoFileSystem extends CryptoFolder implements FileSystem {
@Override
public void create(FolderCreateMode mode) {
physicalDataRoot().create(mode);
- physicalMetadataRoot().create(mode);
final File dirFile = physicalFile();
final String directoryId = getDirectoryId();
try (WritableFile writable = dirFile.openWritable(1, TimeUnit.SECONDS)) {
diff --git a/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFolder.java b/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFolder.java
index c86c89885..13b8ae220 100644
--- a/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFolder.java
+++ b/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoFolder.java
@@ -12,8 +12,6 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -21,7 +19,6 @@ import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
-import org.apache.commons.codec.binary.Base32;
import org.apache.commons.lang3.StringUtils;
import org.cryptomator.crypto.engine.Cryptor;
import org.cryptomator.filesystem.File;
@@ -41,9 +38,8 @@ class CryptoFolder extends CryptoNode implements Folder {
super(parent, name, cryptor);
}
- @Override
String encryptedName() {
- return name() + FILE_EXT;
+ return cryptor.getFilenameCryptor().encryptFilename(name()) + FILE_EXT;
}
protected String getDirectoryId() {
@@ -72,14 +68,7 @@ class CryptoFolder extends CryptoNode implements Folder {
}
Folder physicalFolder() {
- final String encryptedThenHashedDirId;
- try {
- final byte[] hash = MessageDigest.getInstance("SHA-1").digest(getDirectoryId().getBytes());
- encryptedThenHashedDirId = new Base32().encodeAsString(hash);
- } catch (NoSuchAlgorithmException e) {
- throw new AssertionError("SHA-1 exists in every JVM");
- }
- // TODO actual encryption
+ final String encryptedThenHashedDirId = cryptor.getFilenameCryptor().hashDirectoryId(getDirectoryId());
return physicalDataRoot().folder(encryptedThenHashedDirId.substring(0, 2)).folder(encryptedThenHashedDirId.substring(2));
}
diff --git a/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoNode.java b/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoNode.java
index de7ec1c65..10feba12a 100644
--- a/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoNode.java
+++ b/main/crypto-layer/src/main/java/org/cryptomator/crypto/fs/CryptoNode.java
@@ -30,10 +30,6 @@ abstract class CryptoNode implements Node {
return parent.physicalDataRoot();
}
- Folder physicalMetadataRoot() {
- return parent.physicalMetadataRoot();
- }
-
@Override
public Optional parent() {
return Optional.of(parent);
@@ -44,10 +40,6 @@ abstract class CryptoNode implements Node {
return name;
}
- String encryptedName() {
- return name();
- }
-
@Override
public boolean exists() {
return parent.children().anyMatch(node -> node.equals(this));
diff --git a/main/crypto-layer/src/test/java/org/cryptomator/crypto/engine/impl/CryptorImplTest.java b/main/crypto-layer/src/test/java/org/cryptomator/crypto/engine/impl/CryptorImplTest.java
index c6cee4b48..f4127a45f 100644
--- a/main/crypto-layer/src/test/java/org/cryptomator/crypto/engine/impl/CryptorImplTest.java
+++ b/main/crypto-layer/src/test/java/org/cryptomator/crypto/engine/impl/CryptorImplTest.java
@@ -11,8 +11,10 @@ package org.cryptomator.crypto.engine.impl;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicReference;
import org.cryptomator.crypto.engine.Cryptor;
+import org.cryptomator.crypto.engine.FilenameCryptor;
import org.junit.Assert;
import org.junit.Test;
@@ -50,4 +52,46 @@ public class CryptorImplTest {
Assert.assertArrayEquals(expectedMasterKey.getBytes(), masterkeyFile);
}
+ @Test
+ public void testGetFilenameCryptorAfterUnlocking() {
+ final String testMasterKey = "{\"version\":3,\"scryptSalt\":\"AAAAAAAAAAA=\",\"scryptCostParam\":2,\"scryptBlockSize\":8," //
+ + "\"primaryMasterKey\":\"mM+qoQ+o0qvPTiDAZYt+flaC3WbpNAx1sTXaUzxwpy0M9Ctj6Tih/Q==\"," //
+ + "\"hmacMasterKey\":\"mM+qoQ+o0qvPTiDAZYt+flaC3WbpNAx1sTXaUzxwpy0M9Ctj6Tih/Q==\"}";
+ final Cryptor cryptor = new CryptorImpl(RANDOM_MOCK);
+ cryptor.readKeysFromMasterkeyFile(testMasterKey.getBytes(), "asd");
+ Assert.assertNotNull(cryptor.getFilenameCryptor());
+ }
+
+ @Test(expected = RuntimeException.class)
+ public void testGetFilenameCryptorBeforeUnlocking() {
+ final Cryptor cryptor = new CryptorImpl(RANDOM_MOCK);
+ cryptor.getFilenameCryptor();
+ }
+
+ @Test
+ public void testConcurrentGetFilenameCryptor() throws InterruptedException {
+ final String testMasterKey = "{\"version\":3,\"scryptSalt\":\"AAAAAAAAAAA=\",\"scryptCostParam\":2,\"scryptBlockSize\":8," //
+ + "\"primaryMasterKey\":\"mM+qoQ+o0qvPTiDAZYt+flaC3WbpNAx1sTXaUzxwpy0M9Ctj6Tih/Q==\"," //
+ + "\"hmacMasterKey\":\"mM+qoQ+o0qvPTiDAZYt+flaC3WbpNAx1sTXaUzxwpy0M9Ctj6Tih/Q==\"}";
+ final Cryptor cryptor = new CryptorImpl(RANDOM_MOCK);
+ cryptor.readKeysFromMasterkeyFile(testMasterKey.getBytes(), "asd");
+
+ final AtomicReference receivedByT1 = new AtomicReference<>();
+ final Thread t1 = new Thread(() -> {
+ receivedByT1.set(cryptor.getFilenameCryptor());
+ });
+
+ final AtomicReference receivedByT2 = new AtomicReference<>();
+ final Thread t2 = new Thread(() -> {
+ receivedByT2.set(cryptor.getFilenameCryptor());
+ });
+ t1.start();
+ t2.start();
+ t1.join();
+ t2.join();
+ // It is not guaranteed, both threads will enter getFilenameCryptor() exactly simultaneously. (But logging shows it is very likely)
+ // In any case both threads should receive the same FilenameCryptor
+ Assert.assertSame(receivedByT1.get(), receivedByT2.get());
+ }
+
}
diff --git a/main/crypto-layer/src/test/java/org/cryptomator/crypto/fs/CryptoFileSystemTest.java b/main/crypto-layer/src/test/java/org/cryptomator/crypto/fs/CryptoFileSystemTest.java
index 20b8bfa29..276f5c171 100644
--- a/main/crypto-layer/src/test/java/org/cryptomator/crypto/fs/CryptoFileSystemTest.java
+++ b/main/crypto-layer/src/test/java/org/cryptomator/crypto/fs/CryptoFileSystemTest.java
@@ -49,7 +49,7 @@ public class CryptoFileSystemTest {
Assert.assertTrue(masterkeyBkupFile.exists());
fs.create(FolderCreateMode.INCLUDING_PARENTS);
Assert.assertTrue(physicalDataRoot.exists());
- Assert.assertEquals(4, physicalFs.children().count()); // d + m + masterkey.cryptomator + masterkey.cryptomator.bkup
+ Assert.assertEquals(3, physicalFs.children().count()); // d + masterkey.cryptomator + masterkey.cryptomator.bkup
Assert.assertEquals(1, physicalDataRoot.files().count()); // ROOT file
Assert.assertEquals(1, physicalDataRoot.folders().count()); // ROOT directory
diff --git a/main/crypto-layer/src/test/java/org/cryptomator/crypto/fs/EncryptAndShortenIntegrationTest.java b/main/crypto-layer/src/test/java/org/cryptomator/crypto/fs/EncryptAndShortenIntegrationTest.java
new file mode 100644
index 000000000..ee05654e3
--- /dev/null
+++ b/main/crypto-layer/src/test/java/org/cryptomator/crypto/fs/EncryptAndShortenIntegrationTest.java
@@ -0,0 +1,62 @@
+package org.cryptomator.crypto.fs;
+
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+import org.cryptomator.crypto.engine.Cryptor;
+import org.cryptomator.crypto.engine.impl.CryptorImpl;
+import org.cryptomator.filesystem.FileSystem;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+import org.cryptomator.filesystem.Node;
+import org.cryptomator.filesystem.inmem.InMemoryFileSystem;
+import org.cryptomator.shortening.ShorteningFileSystem;
+import org.junit.Assert;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class EncryptAndShortenIntegrationTest {
+
+ private static final Logger LOG = LoggerFactory.getLogger(EncryptAndShortenIntegrationTest.class);
+
+ private static final SecureRandom RANDOM_MOCK = new SecureRandom() {
+
+ private static final long serialVersionUID = 1505563778398085504L;
+
+ @Override
+ public void nextBytes(byte[] bytes) {
+ Arrays.fill(bytes, (byte) 0x00);
+ }
+
+ };
+
+ @Test
+ public void testEncryptionOfLongFolderNames() {
+ final FileSystem physicalFs = new InMemoryFileSystem();
+ final FileSystem shorteningFs = new ShorteningFileSystem(physicalFs, physicalFs.folder("m"), 70);
+ final Cryptor cryptor = new CryptorImpl(RANDOM_MOCK);
+ cryptor.randomizeMasterkey();
+ final FileSystem fs = new CryptoFileSystem(shorteningFs, cryptor, "foo");
+ fs.create(FolderCreateMode.FAIL_IF_PARENT_IS_MISSING);
+ final Folder shortFolder = fs.folder("normal folder name");
+ shortFolder.create(FolderCreateMode.FAIL_IF_PARENT_IS_MISSING);
+ final Folder longFolder = fs.folder("this will be a long filename after encryption");
+ longFolder.create(FolderCreateMode.FAIL_IF_PARENT_IS_MISSING);
+
+ // the long name will produce a metadata file on the physical layer:
+ LOG.debug("Physical file system:\n" + DirectoryPrinter.print(physicalFs));
+ Assert.assertEquals(1, physicalFs.folder("m").folders().count());
+
+ // on the second layer all .lng files are resolved to their actual names:
+ LOG.debug("Unlimited filename length:\n" + DirectoryPrinter.print(shorteningFs));
+ DirectoryWalker.walk(shorteningFs, node -> {
+ Assert.assertFalse(node.name().endsWith(".lng"));
+ });
+
+ // on the third (cleartext layer) we have cleartext names on the root level:
+ LOG.debug("Cleartext files:\n" + DirectoryPrinter.print(fs));
+ Assert.assertArrayEquals(new String[] {"normal folder name", "this will be a long filename after encryption"}, fs.folders().map(Node::name).sorted().toArray());
+ }
+
+}
diff --git a/main/pom.xml b/main/pom.xml
index a8ac6baba..d759f2fd8 100644
--- a/main/pom.xml
+++ b/main/pom.xml
@@ -64,6 +64,11 @@
filesystem-inmemory
${project.version}
+
+ org.cryptomator
+ shortening-layer
+ ${project.version}
+
org.cryptomator
crypto-layer
@@ -215,6 +220,7 @@
crypto-aes
core
ui
+ shortening-layer
diff --git a/main/shortening-layer/.gitignore b/main/shortening-layer/.gitignore
new file mode 100644
index 000000000..b83d22266
--- /dev/null
+++ b/main/shortening-layer/.gitignore
@@ -0,0 +1 @@
+/target/
diff --git a/main/shortening-layer/pom.xml b/main/shortening-layer/pom.xml
new file mode 100644
index 000000000..01cfd1366
--- /dev/null
+++ b/main/shortening-layer/pom.xml
@@ -0,0 +1,58 @@
+
+
+
+ 4.0.0
+
+ org.cryptomator
+ main
+ 0.11.0-SNAPSHOT
+
+ shortening-layer
+ Cryptomator name shortening filesystem layer
+
+
+
+ org.cryptomator
+ filesystem-api
+
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+ commons-codec
+ commons-codec
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+
+
+ org.cryptomator
+ filesystem-inmemory
+ test
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+
+
+
+
\ No newline at end of file
diff --git a/main/shortening-layer/src/main/java/org/cryptomator/shortening/FilenameShortener.java b/main/shortening-layer/src/main/java/org/cryptomator/shortening/FilenameShortener.java
new file mode 100644
index 000000000..10286b8b1
--- /dev/null
+++ b/main/shortening-layer/src/main/java/org/cryptomator/shortening/FilenameShortener.java
@@ -0,0 +1,111 @@
+package org.cryptomator.shortening;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.commons.codec.binary.Base32;
+import org.apache.commons.codec.binary.BaseNCodec;
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+import org.cryptomator.filesystem.ReadableFile;
+import org.cryptomator.filesystem.WritableFile;
+
+class FilenameShortener {
+
+ private static final String LONG_NAME_FILE_EXT = ".lng";
+ private static final ThreadLocal SHA1 = new ThreadLocalSha1();
+ private static final BaseNCodec BASE32 = new Base32();
+ private final Folder metadataRoot;
+ private final int threshold;
+
+ public FilenameShortener(Folder metadataRoot, int threshold) {
+ this.metadataRoot = metadataRoot;
+ this.threshold = threshold;
+ }
+
+ public String inflate(String shortName) {
+ if (shortName.endsWith(LONG_NAME_FILE_EXT)) {
+ return loadMapping(shortName);
+ } else {
+ return shortName;
+ }
+ }
+
+ public String deflate(String longName) {
+ if (longName.length() < threshold) {
+ return longName;
+ } else {
+ final byte[] hashBytes = SHA1.get().digest(longName.getBytes());
+ final String hash = BASE32.encodeAsString(hashBytes);
+ return hash + LONG_NAME_FILE_EXT;
+ }
+ }
+
+ public boolean isShortened(String name) {
+ return name.endsWith(LONG_NAME_FILE_EXT);
+ }
+
+ public void saveMapping(String longName, String shortName) {
+ final File mappingFile = mappingFile(shortName);
+ if (!mappingFile.exists()) {
+ mappingFile.parent().get().create(FolderCreateMode.INCLUDING_PARENTS);
+ try (WritableFile writable = mappingFile.openWritable(1, TimeUnit.SECONDS)) {
+ writable.write(ByteBuffer.wrap(longName.getBytes(StandardCharsets.UTF_8)));
+ } catch (TimeoutException e) {
+ throw new UncheckedIOException(new IOException("Failed to lock mapping file in time. " + mappingFile, e));
+ }
+ }
+ }
+
+ private File mappingFile(String deflated) {
+ final Folder folder = metadataRoot.folder(deflated.substring(0, 2)).folder(deflated.substring(2, 4));
+ return folder.file(deflated);
+ }
+
+ private String loadMapping(String shortName) {
+ final File mappingFile = mappingFile(shortName);
+ if (!mappingFile.exists()) {
+ throw new UncheckedIOException(new FileNotFoundException("Mapping file not found " + mappingFile));
+ } else {
+ try (ReadableFile readable = mappingFile.openReadable(1, TimeUnit.SECONDS)) {
+ // TODO buffer might be to small
+ final ByteBuffer buf = ByteBuffer.allocate(1024);
+ readable.read(buf);
+ buf.flip();
+ final byte[] bytes = new byte[buf.remaining()];
+ buf.get(bytes);
+ return new String(bytes, StandardCharsets.UTF_8);
+ } catch (TimeoutException e) {
+ throw new UncheckedIOException(new IOException("Failed to lock mapping file in time. " + mappingFile, e));
+ }
+ }
+ }
+
+ private static class ThreadLocalSha1 extends ThreadLocal {
+
+ @Override
+ protected MessageDigest initialValue() {
+ try {
+ return MessageDigest.getInstance("SHA-1");
+ } catch (NoSuchAlgorithmException e) {
+ throw new AssertionError("SHA-1 exists in every JVM");
+ }
+ }
+
+ @Override
+ public MessageDigest get() {
+ final MessageDigest messageDigest = super.get();
+ messageDigest.reset();
+ return messageDigest;
+ }
+ }
+
+}
diff --git a/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFile.java b/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFile.java
new file mode 100644
index 000000000..75c4680e5
--- /dev/null
+++ b/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFile.java
@@ -0,0 +1,38 @@
+package org.cryptomator.shortening;
+
+import java.io.UncheckedIOException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.ReadableFile;
+import org.cryptomator.filesystem.WritableFile;
+
+class ShorteningFile extends ShorteningNodeimplements File {
+
+ private final FilenameShortener shortener;
+
+ public ShorteningFile(ShorteningFolder parent, File delegate, String longName, FilenameShortener shortener) {
+ super(parent, delegate, longName);
+ this.shortener = shortener;
+ }
+
+ @Override
+ public ReadableFile openReadable(long timeout, TimeUnit unit) throws UncheckedIOException, TimeoutException {
+ return delegate.openReadable(timeout, unit);
+ }
+
+ @Override
+ public WritableFile openWritable(long timeout, TimeUnit unit) throws UncheckedIOException, TimeoutException {
+ if (shortener.isShortened(shortName())) {
+ shortener.saveMapping(name(), shortName());
+ }
+ return delegate.openWritable(timeout, unit);
+ }
+
+ @Override
+ public String toString() {
+ return name();
+ }
+
+}
diff --git a/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFileSystem.java b/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFileSystem.java
new file mode 100644
index 000000000..3923d1837
--- /dev/null
+++ b/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFileSystem.java
@@ -0,0 +1,29 @@
+package org.cryptomator.shortening;
+
+import java.util.Optional;
+
+import org.cryptomator.filesystem.FileSystem;
+import org.cryptomator.filesystem.Folder;
+
+public class ShorteningFileSystem extends ShorteningFolder implements FileSystem {
+
+ public ShorteningFileSystem(Folder root, Folder metadataRoot, int threshold) {
+ super(null, root, "", metadataRoot, new FilenameShortener(metadataRoot, threshold));
+ }
+
+ @Override
+ public Optional parent() {
+ return Optional.empty();
+ }
+
+ @Override
+ public boolean exists() {
+ return true;
+ }
+
+ @Override
+ public void delete() {
+ // no-op.
+ }
+
+}
diff --git a/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFolder.java b/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFolder.java
new file mode 100644
index 000000000..df0295c33
--- /dev/null
+++ b/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningFolder.java
@@ -0,0 +1,115 @@
+package org.cryptomator.shortening;
+
+import java.io.FileNotFoundException;
+import java.io.UncheckedIOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+import org.cryptomator.filesystem.Node;
+
+class ShorteningFolder extends ShorteningNodeimplements Folder {
+
+ private final Folder metadataRoot;
+ private final FilenameShortener shortener;
+
+ public ShorteningFolder(ShorteningFolder parent, Folder delegate, String longName, Folder metadataRoot, FilenameShortener shortener) {
+ super(parent, delegate, longName);
+ this.metadataRoot = metadataRoot;
+ this.shortener = shortener;
+ }
+
+ @Override
+ public Stream extends Node> children() {
+ return Stream.concat(this.files(), this.folders());
+ }
+
+ private ShorteningFile existingFile(File original) {
+ final String longName = shortener.inflate(original.name());
+ return new ShorteningFile(this, original, longName, shortener);
+ }
+
+ @Override
+ public File file(String name) {
+ final File original = delegate.file(shortener.deflate(name));
+ if (metadataRoot.equals(original)) { // comparing apples and oranges, but we don't know if the underlying fs distinguishes files and folders...
+ throw new UncheckedIOException("'" + name + "' is a reserved name.", new FileAlreadyExistsException(name));
+ }
+ return new ShorteningFile(this, original, name, shortener);
+ }
+
+ @Override
+ public Stream extends File> files() throws UncheckedIOException {
+ return delegate.files().map(this::existingFile);
+ }
+
+ private ShorteningFolder existingFolder(Folder original) {
+ final String longName = shortener.inflate(original.name());
+ return new ShorteningFolder(this, original, longName, metadataRoot, shortener);
+ }
+
+ @Override
+ public Folder folder(String name) {
+ final Folder original = delegate.folder(shortener.deflate(name));
+ if (metadataRoot.equals(original)) {
+ throw new UncheckedIOException("'" + name + "' is a reserved name.", new FileAlreadyExistsException(name));
+ }
+ return new ShorteningFolder(this, original, name, metadataRoot, shortener);
+ }
+
+ @Override
+ public Stream extends Folder> folders() {
+ // if metadataRoot is inside our filesystem, we must filter it out:
+ final Predicate equalsMetadataRoot = (Node node) -> metadataRoot.equals(node);
+ return delegate.folders().filter(equalsMetadataRoot.negate()).map(this::existingFolder);
+ }
+
+ @Override
+ public void create(FolderCreateMode mode) {
+ if (!parent().get().exists() && FolderCreateMode.FAIL_IF_PARENT_IS_MISSING.equals(mode)) {
+ throw new UncheckedIOException(new FileNotFoundException(parent().get().name()));
+ } else if (!parent().get().exists() && FolderCreateMode.INCLUDING_PARENTS.equals(mode)) {
+ parent().get().create(mode);
+ }
+ assert parent().get().exists();
+ if (shortener.isShortened(shortName())) {
+ shortener.saveMapping(name(), shortName());
+ }
+ delegate.create(mode);
+ }
+
+ @Override
+ public void delete() {
+ delegate.delete();
+ }
+
+ @Override
+ public void moveTo(Folder target) {
+ if (target instanceof ShorteningFolder) {
+ moveToInternal((ShorteningFolder) target);
+ } else {
+ throw new UnsupportedOperationException("Can not move ShorteningFolder to conventional folder.");
+ }
+ }
+
+ private void moveToInternal(ShorteningFolder target) {
+ if (this.isAncestorOf(target) || target.isAncestorOf(this)) {
+ throw new IllegalArgumentException("Can not move directories containing one another (src: " + this + ", dst: " + target + ")");
+ }
+
+ if (!target.exists()) {
+ target.create(FolderCreateMode.INCLUDING_PARENTS);
+ }
+
+ delegate.moveTo(target.delegate);
+ }
+
+ @Override
+ public String toString() {
+ return name() + "/";
+ }
+
+}
diff --git a/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningNode.java b/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningNode.java
new file mode 100644
index 000000000..d3a62983c
--- /dev/null
+++ b/main/shortening-layer/src/main/java/org/cryptomator/shortening/ShorteningNode.java
@@ -0,0 +1,68 @@
+package org.cryptomator.shortening;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.Node;
+
+class ShorteningNode implements Node {
+
+ protected final E delegate;
+ private final ShorteningFolder parent;
+ private final String longName;
+ private final String shortName;
+
+ public ShorteningNode(ShorteningFolder parent, E delegate, String longName) {
+ this.delegate = delegate;
+ this.parent = parent;
+ this.shortName = delegate.name();
+ this.longName = longName;
+ }
+
+ @Override
+ public String name() {
+ return longName;
+ }
+
+ protected String shortName() {
+ return shortName;
+ }
+
+ @Override
+ public Optional extends Folder> parent() {
+ return Optional.ofNullable(parent);
+ }
+
+ @Override
+ public boolean exists() {
+ return delegate.exists();
+ }
+
+ @Override
+ public Instant lastModified() {
+ return delegate.lastModified();
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((longName == null) ? 0 : longName.hashCode());
+ result = prime * result + ((parent == null) ? 0 : parent.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof ShorteningNode) {
+ ShorteningNode> other = (ShorteningNode>) obj;
+ return this.getClass() == other.getClass() //
+ && (this.parent == null && other.parent == null || this.parent.equals(other.parent)) //
+ && (this.longName == null && other.longName == null || this.longName.equals(other.longName));
+ } else {
+ return false;
+ }
+ }
+
+}
diff --git a/main/shortening-layer/src/main/java/org/cryptomator/shortening/package-info.java b/main/shortening-layer/src/main/java/org/cryptomator/shortening/package-info.java
new file mode 100644
index 000000000..26f68c001
--- /dev/null
+++ b/main/shortening-layer/src/main/java/org/cryptomator/shortening/package-info.java
@@ -0,0 +1,6 @@
+/**
+ * Provides a decoration layer for the {@link org.cryptomator.filesystem Filesystem API}.
+ * {@link org.cryptomator.filesystem.File File} and {@link org.cryptomator.filesystem.Folder Folder} names exceeding a certain length limit will be mapped to shorter equivalents.
+ * The mapping itself is stored in metadata files inside the m/ directory on root level.
+ */
+package org.cryptomator.shortening;
\ No newline at end of file
diff --git a/main/shortening-layer/src/test/java/org/cryptomator/shortening/ShorteningFileSystemTest.java b/main/shortening-layer/src/test/java/org/cryptomator/shortening/ShorteningFileSystemTest.java
new file mode 100644
index 000000000..0917fb2d6
--- /dev/null
+++ b/main/shortening-layer/src/test/java/org/cryptomator/shortening/ShorteningFileSystemTest.java
@@ -0,0 +1,115 @@
+package org.cryptomator.shortening;
+
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.FileSystem;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+import org.cryptomator.filesystem.ReadableFile;
+import org.cryptomator.filesystem.WritableFile;
+import org.cryptomator.filesystem.inmem.InMemoryFileSystem;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ShorteningFileSystemTest {
+
+ @Test
+ public void testCreationOfInvisibleMetadataFolder() {
+ final FileSystem underlyingFs = new InMemoryFileSystem();
+ final Folder metadataRoot = underlyingFs.folder("m");
+ final FileSystem fs = new ShorteningFileSystem(underlyingFs, metadataRoot, 10);
+ fs.folder("morethantenchars").create(FolderCreateMode.FAIL_IF_PARENT_IS_MISSING);
+ Assert.assertTrue(metadataRoot.exists());
+ Assert.assertEquals(1, fs.folders().count());
+ }
+
+ @Test(expected = UncheckedIOException.class)
+ public void testPreventCreationOfMetadataFolder() {
+ final FileSystem underlyingFs = new InMemoryFileSystem();
+ final Folder metadataRoot = underlyingFs.folder("m");
+ final FileSystem fs = new ShorteningFileSystem(underlyingFs, metadataRoot, 10);
+ fs.folder("m");
+ }
+
+ @Test
+ public void testDeflate() {
+ final FileSystem underlyingFs = new InMemoryFileSystem();
+ final Folder metadataRoot = underlyingFs.folder("m");
+ final FileSystem fs = new ShorteningFileSystem(underlyingFs, metadataRoot, 10);
+ final Folder longNamedFolder = fs.folder("morethantenchars"); // base32(sha1(morethantenchars)) = QMJL5GQUETRX2YRV6XDTJQ6NNM7IEUHP
+ final File correspondingMetadataFile = metadataRoot.folder("QM").folder("JL").file("QMJL5GQUETRX2YRV6XDTJQ6NNM7IEUHP.lng");
+ longNamedFolder.create(FolderCreateMode.FAIL_IF_PARENT_IS_MISSING);
+ Assert.assertTrue(longNamedFolder.exists());
+ Assert.assertTrue(correspondingMetadataFile.exists());
+ }
+
+ @Test
+ public void testDeflateAndInflateFolder() {
+ final FileSystem underlyingFs = new InMemoryFileSystem();
+ final Folder metadataRoot = underlyingFs.folder("m");
+ final FileSystem fs1 = new ShorteningFileSystem(underlyingFs, metadataRoot, 10);
+ final Folder longNamedFolder1 = fs1.folder("morethantenchars");
+ longNamedFolder1.create(FolderCreateMode.FAIL_IF_PARENT_IS_MISSING);
+
+ final FileSystem fs2 = new ShorteningFileSystem(underlyingFs, metadataRoot, 10);
+ final Folder longNamedFolder2 = fs2.folder("morethantenchars");
+ Assert.assertTrue(longNamedFolder2.exists());
+ }
+
+ @Test
+ public void testDeflateAndInflateFolderAndFile() throws UncheckedIOException, TimeoutException {
+ final FileSystem underlyingFs = new InMemoryFileSystem();
+ final Folder metadataRoot = underlyingFs.folder("m");
+
+ // write:
+ final FileSystem fs1 = new ShorteningFileSystem(underlyingFs, metadataRoot, 10);
+ fs1.folder("morethantenchars").create(FolderCreateMode.INCLUDING_PARENTS);
+ try (WritableFile file = fs1.folder("morethantenchars").file("morethanelevenchars.txt").openWritable(1, TimeUnit.MILLISECONDS)) {
+ file.write(ByteBuffer.wrap("hello world".getBytes()));
+ }
+
+ // read
+ final FileSystem fs2 = new ShorteningFileSystem(underlyingFs, metadataRoot, 10);
+ try (ReadableFile file = fs2.folder("morethantenchars").file("morethanelevenchars.txt").openReadable(1, TimeUnit.MILLISECONDS)) {
+ ByteBuffer buf = ByteBuffer.allocate(11);
+ file.read(buf);
+ Assert.assertEquals("hello world", new String(buf.array()));
+ }
+ }
+
+ @Test
+ public void testPassthroughShortNamedFiles() throws UncheckedIOException, TimeoutException {
+ final FileSystem underlyingFs = new InMemoryFileSystem();
+ final Folder metadataRoot = underlyingFs.folder("m");
+ final FileSystem fs = new ShorteningFileSystem(underlyingFs, metadataRoot, 10);
+
+ // of folders:
+ underlyingFs.folder("foo").folder("bar").create(FolderCreateMode.INCLUDING_PARENTS);
+ Assert.assertTrue(fs.folder("foo").folder("bar").exists());
+
+ // from underlying:
+ try (WritableFile file = underlyingFs.folder("foo").file("test1.txt").openWritable(1, TimeUnit.MILLISECONDS)) {
+ file.write(ByteBuffer.wrap("hello world".getBytes()));
+ }
+ try (ReadableFile file = fs.folder("foo").file("test1.txt").openReadable(1, TimeUnit.MILLISECONDS)) {
+ ByteBuffer buf = ByteBuffer.allocate(11);
+ file.read(buf);
+ Assert.assertEquals("hello world", new String(buf.array()));
+ }
+
+ // to underlying:
+ try (WritableFile file = fs.folder("foo").file("test2.txt").openWritable(1, TimeUnit.MILLISECONDS)) {
+ file.write(ByteBuffer.wrap("hello world".getBytes()));
+ }
+ try (ReadableFile file = underlyingFs.folder("foo").file("test2.txt").openReadable(1, TimeUnit.MILLISECONDS)) {
+ ByteBuffer buf = ByteBuffer.allocate(11);
+ file.read(buf);
+ Assert.assertEquals("hello world", new String(buf.array()));
+ }
+ }
+
+}