diff --git a/pom.xml b/pom.xml index 9746be12..85a7e458 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.cryptomator cryptofs - 1.9.7 + 1.9.8 Cryptomator Crypto Filesystem This library provides the Java filesystem provider used by Cryptomator. https://github.com/cryptomator/cryptofs diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 727aae13..054d4e93 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -38,6 +38,7 @@ import java.nio.file.DirectoryStream.Filter; import java.nio.file.FileAlreadyExistsException; import java.nio.file.FileStore; +import java.nio.file.FileSystemException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.NoSuchFileException; @@ -91,6 +92,7 @@ class CryptoFileSystemImpl extends CryptoFileSystem { private final FinallyUtil finallyUtil; private final CiphertextDirectoryDeleter ciphertextDirDeleter; private final ReadonlyFlag readonlyFlag; + private final CryptoFileSystemProperties fileSystemProperties; private final CryptoPath rootPath; private final CryptoPath emptyPath; @@ -102,7 +104,8 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems CryptoFileStore fileStore, CryptoFileSystemStats stats, CryptoPathMapper cryptoPathMapper, CryptoPathFactory cryptoPathFactory, PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider, AttributeProvider fileAttributeProvider, AttributeByNameProvider fileAttributeByNameProvider, AttributeViewProvider fileAttributeViewProvider, - OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag, RootDirectoryInitializer rootDirectoryInitializer) { + OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag, + CryptoFileSystemProperties fileSystemProperties, RootDirectoryInitializer rootDirectoryInitializer) { this.provider = provider; this.cryptoFileSystems = cryptoFileSystems; this.pathToVault = pathToVault; @@ -122,6 +125,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems this.finallyUtil = finallyUtil; this.ciphertextDirDeleter = ciphertextDirDeleter; this.readonlyFlag = readonlyFlag; + this.fileSystemProperties = fileSystemProperties; this.rootPath = cryptoPathFactory.rootFor(this); this.emptyPath = cryptoPathFactory.emptyFor(this); @@ -299,6 +303,7 @@ void createDirectory(CryptoPath cleartextDir, FileAttribute... attrs) throws cryptoPathMapper.assertNonExisting(cleartextDir); CiphertextFilePath ciphertextPath = cryptoPathMapper.getCiphertextFilePath(cleartextDir); Path ciphertextDirFile = ciphertextPath.getDirFilePath(); + assertCiphertextPathLengthMeetsLimitations(ciphertextDirFile); CiphertextDirectory ciphertextDir = cryptoPathMapper.getCiphertextDir(cleartextDir); // atomically check for FileAlreadyExists and create otherwise: Files.createDirectory(ciphertextPath.getRawPath()); @@ -355,6 +360,7 @@ FileChannel newFileChannel(CryptoPath cleartextPath, Set o private FileChannel newFileChannel(CryptoPath cleartextFilePath, EffectiveOpenOptions options, FileAttribute... attrs) throws IOException { CiphertextFilePath ciphertextPath = cryptoPathMapper.getCiphertextFilePath(cleartextFilePath); Path ciphertextFilePath = ciphertextPath.getFilePath(); + assertCiphertextPathLengthMeetsLimitations(ciphertextFilePath); if (options.createNew() && openCryptoFiles.get(ciphertextFilePath).isPresent()) { throw new FileAlreadyExistsException(cleartextFilePath.toString()); } else { @@ -430,8 +436,10 @@ private void copySymlink(CryptoPath cleartextSource, CryptoPath cleartextTarget, if (ArrayUtils.contains(options, LinkOption.NOFOLLOW_LINKS)) { CiphertextFilePath ciphertextSourceFile = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTargetFile = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); + assertCiphertextPathLengthMeetsLimitations(ciphertextTargetFile.getSymlinkFilePath()); CopyOption[] resolvedOptions = ArrayUtils.without(options, LinkOption.NOFOLLOW_LINKS).toArray(CopyOption[]::new); - Files.copy(ciphertextSourceFile.getRawPath(), ciphertextTargetFile.getRawPath(), resolvedOptions); + Files.createDirectories(ciphertextTargetFile.getRawPath()); + Files.copy(ciphertextSourceFile.getSymlinkFilePath(), ciphertextTargetFile.getSymlinkFilePath(), resolvedOptions); ciphertextTargetFile.persistLongFileName(); } else { CryptoPath resolvedSource = symlinks.resolveRecursively(cleartextSource); @@ -444,6 +452,7 @@ private void copySymlink(CryptoPath cleartextSource, CryptoPath cleartextTarget, private void copyFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, CopyOption[] options) throws IOException { CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); + assertCiphertextPathLengthMeetsLimitations(ciphertextTarget.getFilePath()); if (ciphertextTarget.isShortened()) { Files.createDirectories(ciphertextTarget.getRawPath()); } @@ -452,7 +461,7 @@ private void copyFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, Co } private void copyDirectory(CryptoPath cleartextSource, CryptoPath cleartextTarget, CopyOption[] options) throws IOException { - // DIRECTORY (non-recursive as per contract): + // non-recursive as per contract: CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); if (Files.notExists(ciphertextTarget.getRawPath())) { // create new: @@ -534,6 +543,7 @@ private void moveSymlink(CryptoPath cleartextSource, CryptoPath cleartextTarget, // "the symbolic link itself, not the target of the link, is moved" CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); + assertCiphertextPathLengthMeetsLimitations(ciphertextTarget.getSymlinkFilePath()); try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { Files.move(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath(), options); if (ciphertextTarget.isShortened()) { @@ -550,6 +560,7 @@ private void moveFile(CryptoPath cleartextSource, CryptoPath cleartextTarget, Co // we need to re-map the OpenCryptoFile entry. CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); + assertCiphertextPathLengthMeetsLimitations(ciphertextTarget.getFilePath()); try (OpenCryptoFiles.TwoPhaseMove twoPhaseMove = openCryptoFiles.prepareMove(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath())) { if (ciphertextTarget.isShortened()) { Files.createDirectory(ciphertextTarget.getRawPath()); @@ -568,6 +579,7 @@ private void moveDirectory(CryptoPath cleartextSource, CryptoPath cleartextTarge // Hence there is no need to re-map OpenCryptoFile entries. CiphertextFilePath ciphertextSource = cryptoPathMapper.getCiphertextFilePath(cleartextSource); CiphertextFilePath ciphertextTarget = cryptoPathMapper.getCiphertextFilePath(cleartextTarget); + assertCiphertextPathLengthMeetsLimitations(ciphertextTarget.getDirFilePath()); if (ArrayUtils.contains(options, StandardCopyOption.REPLACE_EXISTING)) { // check if not attempting to move atomically: if (ArrayUtils.contains(options, StandardCopyOption.ATOMIC_MOVE)) { @@ -607,6 +619,8 @@ CryptoFileStore getFileStore() { void createSymbolicLink(CryptoPath cleartextPath, Path target, FileAttribute... attrs) throws IOException { assertOpen(); + CiphertextFilePath ciphertextFilePath = cryptoPathMapper.getCiphertextFilePath(cleartextPath); + assertCiphertextPathLengthMeetsLimitations(ciphertextFilePath.getSymlinkFilePath()); symlinks.createSymbolicLink(cleartextPath, target, attrs); } @@ -624,6 +638,13 @@ CryptoPath getRootPath() { CryptoPath getEmptyPath() { return emptyPath; } + + void assertCiphertextPathLengthMeetsLimitations(Path cdrFilePath) throws FileNameTooLongException { + String vaultRelativePath = pathToVault.relativize(cdrFilePath).toString(); + if (vaultRelativePath.length() > fileSystemProperties.maxPathLength()) { + throw new FileNameTooLongException(vaultRelativePath, fileSystemProperties.maxPathLength()); + } + } void assertOpen() { if (!open) { diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java index 0acdcdde..356a7f35 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java @@ -25,6 +25,7 @@ import java.util.function.Consumer; import com.google.common.base.Strings; +import org.cryptomator.cryptofs.common.Constants; /** * Properties to pass to @@ -42,6 +43,15 @@ public class CryptoFileSystemProperties extends AbstractMap { */ public static final String PROPERTY_PASSPHRASE = "passphrase"; + /** + * Key identifying the pepper used during key derivation. + * + * @since 1.9.8 + */ + public static final String PROPERTY_MAX_PATH_LENGTH = "maxPathLength"; + + static final int DEFAULT_MAX_PATH_LENGTH = Constants.MAX_CIPHERTEXT_PATH_LENGTH; + /** * Key identifying the pepper used during key derivation. * @@ -94,7 +104,16 @@ public enum FileSystemFlags { * * @deprecated Will get removed in version 2.0.0. Use {@link CryptoFileSystemProvider#initialize(Path, String, CharSequence)} explicitly. */ - @Deprecated INIT_IMPLICITLY + @Deprecated INIT_IMPLICITLY, + + /** + * If present, the maximum ciphertext path length (beginning from the root of the vault directory). + *

+ * If exceeding the limit during a file operation, an exception is thrown. + * + * @since 1.9.8 + */ + MAX_PATH_LENGTH, }; private final Set> entries; @@ -104,7 +123,8 @@ private CryptoFileSystemProperties(Builder builder) { entry(PROPERTY_PASSPHRASE, builder.passphrase), // entry(PROPERTY_PEPPER, builder.pepper), // entry(PROPERTY_FILESYSTEM_FLAGS, builder.flags), // - entry(PROPERTY_MASTERKEY_FILENAME, builder.masterkeyFilename) // + entry(PROPERTY_MASTERKEY_FILENAME, builder.masterkeyFilename), // + entry(PROPERTY_MAX_PATH_LENGTH, builder.maxPathLength) // ))); } @@ -137,6 +157,10 @@ String masterkeyFilename() { return (String) get(PROPERTY_MASTERKEY_FILENAME); } + int maxPathLength() { + return (int) get(PROPERTY_MAX_PATH_LENGTH); + } + @Override public Set> entrySet() { return entries; @@ -220,6 +244,7 @@ public static class Builder { public byte[] pepper = DEFAULT_PEPPER; private final Set flags = EnumSet.copyOf(DEFAULT_FILESYSTEM_FLAGS); private String masterkeyFilename = DEFAULT_MASTERKEY_FILENAME; + private int maxPathLength = DEFAULT_MAX_PATH_LENGTH; private Builder() { } @@ -229,6 +254,7 @@ private Builder(Map properties) { checkedSet(byte[].class, PROPERTY_PEPPER, properties, this::withPepper); checkedSet(String.class, PROPERTY_MASTERKEY_FILENAME, properties, this::withMasterkeyFilename); checkedSet(Set.class, PROPERTY_FILESYSTEM_FLAGS, properties, this::withFlags); + checkedSet(Integer.class, PROPERTY_MAX_PATH_LENGTH, properties, this::withMaxPathLength); } private void checkedSet(Class type, String key, Map properties, Consumer setter) { @@ -253,6 +279,18 @@ public Builder withPassphrase(CharSequence passphrase) { return this; } + /** + * Sets the maximum ciphertext path length for a CryptoFileSystem. + * + * @param maxPathLength The maximum ciphertext path length allowed + * @return this + * @since 1.9.8 + */ + public Builder withMaxPathLength(int maxPathLength) { + this.maxPathLength = maxPathLength; + return this; + } + /** * Sets the pepper for a CryptoFileSystem. * diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProvider.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProvider.java index ef323f29..d585980b 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProvider.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProvider.java @@ -13,6 +13,8 @@ import org.cryptomator.cryptofs.common.FileSystemCapabilityChecker; import org.cryptomator.cryptofs.common.MasterkeyBackupFileHasher; import org.cryptomator.cryptofs.migration.Migrators; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener.ContinuationResult; import org.cryptomator.cryptolib.Cryptors; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.CryptorProvider; @@ -164,7 +166,6 @@ public static void initialize(Path pathToVault, String masterkeyFilename, byte[] if (!Files.isDirectory(pathToVault)) { throw new NotDirectoryException(pathToVault.toString()); } - new FileSystemCapabilityChecker().assertAllCapabilities(pathToVault); try (Cryptor cryptor = CRYPTOR_PROVIDER.createNew()) { // save masterkey file: Path masterKeyPath = pathToVault.resolve(masterkeyFilename); @@ -305,7 +306,7 @@ public CryptoFileSystem newFileSystem(URI uri, Map rawProperties) thr private void migrateFileSystemIfRequired(CryptoFileSystemUri parsedUri, CryptoFileSystemProperties properties) throws IOException, FileSystemNeedsMigrationException { if (Migrators.get().needsMigration(parsedUri.pathToVault(), properties.masterkeyFilename())) { if (properties.migrateImplicitly()) { - Migrators.get().migrate(parsedUri.pathToVault(), properties.masterkeyFilename(), properties.passphrase(), (state, progress) -> {}); + Migrators.get().migrate(parsedUri.pathToVault(), properties.masterkeyFilename(), properties.passphrase(), (state, progress) -> {}, (event) -> ContinuationResult.CANCEL); } else { throw new FileSystemNeedsMigrationException(parsedUri.pathToVault()); } diff --git a/src/main/java/org/cryptomator/cryptofs/FileNameTooLongException.java b/src/main/java/org/cryptomator/cryptofs/FileNameTooLongException.java new file mode 100644 index 00000000..b388c8a5 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/FileNameTooLongException.java @@ -0,0 +1,18 @@ +package org.cryptomator.cryptofs; + +import java.nio.file.FileSystemException; +import java.nio.file.Path; + +/** + * Indicates that an operation failed, as it would result in a ciphertext path that is too long for the underlying file system. + * + * @see org.cryptomator.cryptofs.common.FileSystemCapabilityChecker#determineSupportedPathLength(Path) + * @since 1.9.8 + */ +public class FileNameTooLongException extends FileSystemException { + + public FileNameTooLongException(String c9rPathRelativeToVaultRoot, int maxLength) { + super(c9rPathRelativeToVaultRoot, null, "File path too long. Max ciphertext path name is " + maxLength); + } + +} diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index 69dc3eb7..fac4c3a7 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -14,6 +14,7 @@ public final class Constants { public static final String MASTERKEY_BACKUP_SUFFIX = ".bkup"; public static final String DATA_DIR_NAME = "d"; + public static final int MAX_CIPHERTEXT_PATH_LENGTH = 268; // inclusive, beginning at d/... see https://github.com/cryptomator/cryptofs/issues/77 public static final int MAX_CIPHERTEXT_NAME_LENGTH = 220; // inclusive. calculations done in https://github.com/cryptomator/cryptofs/issues/60#issuecomment-523238303 public static final int MAX_CLEARTEXT_NAME_LENGTH = 146; // inclusive. calculations done in https://github.com/cryptomator/cryptofs/issues/60#issuecomment-523238303 public static final String ROOT_DIR_ID = ""; diff --git a/src/main/java/org/cryptomator/cryptofs/common/FileSystemCapabilityChecker.java b/src/main/java/org/cryptomator/cryptofs/common/FileSystemCapabilityChecker.java index e4330397..f665918f 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/FileSystemCapabilityChecker.java +++ b/src/main/java/org/cryptomator/cryptofs/common/FileSystemCapabilityChecker.java @@ -1,6 +1,5 @@ package org.cryptomator.cryptofs.common; -import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.io.MoreFiles; import com.google.common.io.RecursiveDeleteOption; @@ -8,7 +7,9 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.nio.file.DirectoryIteratorException; import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.FileSystemException; import java.nio.file.Files; import java.nio.file.Path; @@ -16,6 +17,9 @@ public class FileSystemCapabilityChecker { private static final Logger LOG = LoggerFactory.getLogger(FileSystemCapabilityChecker.class); + private static final int MAX_PATH_LEN_REQUIRED = Constants.MAX_CIPHERTEXT_PATH_LENGTH; + private static final int MIN_PATH_LEN_REQUIRED = 64; + private static final String TMP_FS_CHECK_DIR = "temporary-filesystem-capability-check-dir"; // must have 41 chars! public enum Capability { /** @@ -29,18 +33,6 @@ public enum Capability { * @since 1.9.3 */ WRITE_ACCESS, - - /** - * File system supports filenames with ≥ 230 chars. - * @since @since 1.9.2 - */ - LONG_FILENAMES, - - /** - * File system supports paths with ≥ 300 chars. - * @since @since 1.9.2 - */ - LONG_PATHS, } /** @@ -54,8 +46,6 @@ public enum Capability { public void assertAllCapabilities(Path pathToVault) throws MissingCapabilityException { assertReadAccess(pathToVault); assertWriteAccess(pathToVault); - assertLongFilenameSupport(pathToVault); - assertLongFilePathSupport(pathToVault); } /** @@ -81,48 +71,78 @@ public void assertReadAccess(Path pathToVault) throws MissingCapabilityException public void assertWriteAccess(Path pathToVault) throws MissingCapabilityException { Path checkDir = pathToVault.resolve("c"); try { - Files.createDirectory(checkDir); + Files.createDirectories(checkDir); + Path tmpDir = Files.createTempDirectory(checkDir, "write-access"); + Files.delete(tmpDir); } catch (IOException e) { throw new MissingCapabilityException(checkDir, Capability.WRITE_ACCESS); } finally { - deleteSilently(checkDir); + deleteRecursivelySilently(checkDir); } } - - public void assertLongFilenameSupport(Path pathToVault) throws MissingCapabilityException { - String longFileName = Strings.repeat("a", 226) + ".c9r"; - Path checkDir = pathToVault.resolve("c"); - Path p = checkDir.resolve(longFileName); + + public int determineSupportedPathLength(Path pathToVault) { + if (canHandlePathLength(pathToVault, MAX_PATH_LEN_REQUIRED)) { + return MAX_PATH_LEN_REQUIRED; + } else { + return determineSupportedPathLength(pathToVault, MIN_PATH_LEN_REQUIRED, MAX_PATH_LEN_REQUIRED); + } + } + + private int determineSupportedPathLength(Path pathToVault, int lowerBound, int upperBound) { + assert lowerBound <= upperBound; + int mid = (lowerBound + upperBound) / 2; + if (mid == lowerBound) { + return mid; // bounds will not shrink any further at this point + } + if (canHandlePathLength(pathToVault, mid)) { + return determineSupportedPathLength(pathToVault, mid, upperBound); + } else { + return determineSupportedPathLength(pathToVault, lowerBound, mid); + } + } + + private boolean canHandlePathLength(Path pathToVault, int pathLength) { + assert pathLength > 48; + String checkDirStr = "c/" + TMP_FS_CHECK_DIR + String.format("/%03d/", pathLength); + assert checkDirStr.length() == 48; // 268 - 220 + int filenameLength = pathLength - checkDirStr.length(); + Path checkDir = pathToVault.resolve(checkDirStr); + Path checkFile = checkDir.resolve(Strings.repeat("a", filenameLength)); try { - Files.createDirectories(p); - } catch (IOException e) { - throw new MissingCapabilityException(p, Capability.LONG_FILENAMES); + Files.createDirectories(checkDir); + try { + Files.createFile(checkFile); // will fail early on "sane" operating systems, if there is a limit + } catch (FileAlreadyExistsException e) { + // ok + } + try (DirectoryStream ds = Files.newDirectoryStream(checkDir)) { + ds.iterator().hasNext(); // will fail with DirectoryIteratorException on Windows if path of children too long + return true; + } + } catch (DirectoryIteratorException | IOException e) { + return false; } finally { - deleteSilently(checkDir); + deleteSilently(checkFile); // despite not being able to dirlist, we might still be able to delete this + deleteRecursivelySilently(checkDir); // only works if dirlist works, therefore after deleting checkFile } } - - public void assertLongFilePathSupport(Path pathToVault) throws MissingCapabilityException { - String longFileName = Strings.repeat("a", 96) + ".c9r"; - String longPath = Joiner.on('/').join(longFileName, longFileName, longFileName); - Path checkDir = pathToVault.resolve("c"); - Path p = checkDir.resolve(longPath); + + private void deleteSilently(Path path) { try { - Files.createDirectories(p); + Files.delete(path); } catch (IOException e) { - throw new MissingCapabilityException(p, Capability.LONG_PATHS); - } finally { - deleteSilently(checkDir); + LOG.trace("Failed to delete " + path, e); } } - private void deleteSilently(Path dir) { + private void deleteRecursivelySilently(Path dir) { try { if (Files.exists(dir)) { MoreFiles.deleteRecursively(dir, RecursiveDeleteOption.ALLOW_INSECURE); } } catch (IOException e) { - LOG.warn("Failed to clean up " + dir, e); + LOG.trace("Failed to clean up " + dir, e); } } diff --git a/src/main/java/org/cryptomator/cryptofs/migration/Migrators.java b/src/main/java/org/cryptomator/cryptofs/migration/Migrators.java index 18a0c088..a0456fb0 100644 --- a/src/main/java/org/cryptomator/cryptofs/migration/Migrators.java +++ b/src/main/java/org/cryptomator/cryptofs/migration/Migrators.java @@ -17,6 +17,7 @@ import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.FileSystemCapabilityChecker; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener; import org.cryptomator.cryptofs.migration.api.MigrationProgressListener; import org.cryptomator.cryptofs.migration.api.Migrator; import org.cryptomator.cryptofs.migration.api.NoApplicableMigratorException; @@ -33,7 +34,7 @@ *

  * 
  * if (Migrators.get().{@link #needsMigration(Path, String) needsMigration(pathToVault, masterkeyFileName)}) {
- * 	Migrators.get().{@link #migrate(Path, String, CharSequence, MigrationProgressListener) migrate(pathToVault, masterkeyFileName, passphrase, migrationProgressListener)};
+ * 	Migrators.get().{@link #migrate(Path, String, CharSequence, MigrationProgressListener, MigrationContinuationListener) migrate(pathToVault, masterkeyFileName, passphrase, progressListener, continuationListener)};
  * }
  * 
  * 
@@ -92,12 +93,14 @@ public boolean needsMigration(Path pathToVault, String masterkeyFilename) throws * @param pathToVault Path to the vault's root * @param masterkeyFilename Name of the masterkey file located in the vault * @param passphrase The passphrase needed to unlock the vault + * @param progressListener Listener that will get notified of progress updates + * @param continuationListener Listener that will get asked if there are events that require feedback * @throws NoApplicableMigratorException If the vault can not be migrated, because no migrator could be found * @throws InvalidPassphraseException If the passphrase could not be used to unlock the vault * @throws FileSystemCapabilityChecker.MissingCapabilityException If the underlying filesystem lacks features required to store a vault * @throws IOException if an I/O error occurs migrating the vault */ - public void migrate(Path pathToVault, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener) throws NoApplicableMigratorException, InvalidPassphraseException, IOException { + public void migrate(Path pathToVault, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener, MigrationContinuationListener continuationListener) throws NoApplicableMigratorException, InvalidPassphraseException, IOException { fsCapabilityChecker.assertAllCapabilities(pathToVault); Path masterKeyPath = pathToVault.resolve(masterkeyFilename); @@ -106,7 +109,7 @@ public void migrate(Path pathToVault, String masterkeyFilename, CharSequence pas try { Migrator migrator = findApplicableMigrator(keyFile.getVersion()).orElseThrow(NoApplicableMigratorException::new); - migrator.migrate(pathToVault, masterkeyFilename, passphrase, progressListener); + migrator.migrate(pathToVault, masterkeyFilename, passphrase, progressListener, continuationListener); } catch (UnsupportedVaultFormatException e) { // might be a tampered masterkey file, as this exception is also thrown if the vault version MAC is not authentic. throw new IllegalStateException("Vault version checked beforehand but not supported by migrator."); diff --git a/src/main/java/org/cryptomator/cryptofs/migration/api/MigrationContinuationListener.java b/src/main/java/org/cryptomator/cryptofs/migration/api/MigrationContinuationListener.java new file mode 100644 index 00000000..a531565f --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/migration/api/MigrationContinuationListener.java @@ -0,0 +1,30 @@ +package org.cryptomator.cryptofs.migration.api; + +@FunctionalInterface +public interface MigrationContinuationListener { + + /** + * Invoked when the migration requires action. + *

+ * This method is invoked on the thread that runs the migration. + * If you want to perform longer-running actions such as waiting for user feedback on the UI thread, + * consider subclassing {@link SimpleMigrationContinuationListener}. + * + * @param event The migration event that occurred + * @see SimpleMigrationContinuationListener + * @return How to proceed with the migration + */ + ContinuationResult continueMigrationOnEvent(ContinuationEvent event); + + enum ContinuationResult { + CANCEL, PROCEED + } + + enum ContinuationEvent { + /** + * Migrator wants to do a full recursive directory listing. This might take a while. + */ + REQUIRES_FULL_VAULT_DIR_SCAN + } + +} diff --git a/src/main/java/org/cryptomator/cryptofs/migration/api/Migrator.java b/src/main/java/org/cryptomator/cryptofs/migration/api/Migrator.java index 3d6790f6..6973e8ac 100644 --- a/src/main/java/org/cryptomator/cryptofs/migration/api/Migrator.java +++ b/src/main/java/org/cryptomator/cryptofs/migration/api/Migrator.java @@ -41,6 +41,22 @@ default void migrate(Path vaultRoot, String masterkeyFilename, CharSequence pass * @throws UnsupportedVaultFormatException * @throws IOException */ - void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener) throws InvalidPassphraseException, UnsupportedVaultFormatException, IOException; + default void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener) throws InvalidPassphraseException, UnsupportedVaultFormatException, IOException { + migrate(vaultRoot, masterkeyFilename, passphrase, progressListener, (event) -> MigrationContinuationListener.ContinuationResult.CANCEL); + } + + /** + * Performs the migration this migrator is built for. + * + * @param vaultRoot + * @param masterkeyFilename + * @param passphrase + * @param progressListener + * @param continuationListener + * @throws InvalidPassphraseException + * @throws UnsupportedVaultFormatException + * @throws IOException + */ + void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener, MigrationContinuationListener continuationListener) throws InvalidPassphraseException, UnsupportedVaultFormatException, IOException; } diff --git a/src/main/java/org/cryptomator/cryptofs/migration/api/SimpleMigrationContinuationListener.java b/src/main/java/org/cryptomator/cryptofs/migration/api/SimpleMigrationContinuationListener.java new file mode 100644 index 00000000..03befdc9 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/migration/api/SimpleMigrationContinuationListener.java @@ -0,0 +1,54 @@ +package org.cryptomator.cryptofs.migration.api; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public abstract class SimpleMigrationContinuationListener implements MigrationContinuationListener { + + private final Lock lock = new ReentrantLock(); + private final Condition waitForResult = lock.newCondition(); + private final AtomicReference atomicResult = new AtomicReference<>(); + + /** + * Invoked when the migration requires action. + *

+ * Usually you want to ask for user feedback on the UI thread at this point. + * + * @param event The migration event that occurred + * @apiNote This method is called from the migrator thread + */ + public abstract void migrationHaltedDueToEvent(ContinuationEvent event); + + /** + * Continues the migration on its original thread with the desired ContinuationResult. + * + * @param result How to proceed with the migration + * @apiNote This method can be called from any thread. + */ + public final void continueMigrationWithResult(ContinuationResult result) { + lock.lock(); + try { + atomicResult.set(result); + waitForResult.signal(); + } finally { + lock.unlock(); + } + } + + @Override + public final ContinuationResult continueMigrationOnEvent(ContinuationEvent event) { + migrationHaltedDueToEvent(event); + lock.lock(); + try { + waitForResult.await(); + return atomicResult.get(); + } catch (InterruptedException e) { + Thread.interrupted(); + return ContinuationResult.CANCEL; + } finally { + lock.unlock(); + } + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/migration/v6/Version6Migrator.java b/src/main/java/org/cryptomator/cryptofs/migration/v6/Version6Migrator.java index 1505d6aa..33fc970a 100644 --- a/src/main/java/org/cryptomator/cryptofs/migration/v6/Version6Migrator.java +++ b/src/main/java/org/cryptomator/cryptofs/migration/v6/Version6Migrator.java @@ -17,6 +17,7 @@ import org.cryptomator.cryptofs.common.MasterkeyBackupFileHasher; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener; import org.cryptomator.cryptofs.migration.api.MigrationProgressListener; import org.cryptomator.cryptofs.migration.api.Migrator; import org.cryptomator.cryptolib.api.Cryptor; @@ -39,7 +40,7 @@ public Version6Migrator(CryptorProvider cryptorProvider) { } @Override - public void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener) throws InvalidPassphraseException, UnsupportedVaultFormatException, IOException { + public void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener, MigrationContinuationListener continuationListener) throws InvalidPassphraseException, UnsupportedVaultFormatException, IOException { LOG.info("Upgrading {} from version 5 to version 6.", vaultRoot); progressListener.update(MigrationProgressListener.ProgressState.INITIALIZING, 0.0); Path masterkeyFile = vaultRoot.resolve(masterkeyFilename); diff --git a/src/main/java/org/cryptomator/cryptofs/migration/v7/FilePathMigration.java b/src/main/java/org/cryptomator/cryptofs/migration/v7/FilePathMigration.java index 7f33177a..158efcf3 100644 --- a/src/main/java/org/cryptomator/cryptofs/migration/v7/FilePathMigration.java +++ b/src/main/java/org/cryptomator/cryptofs/migration/v7/FilePathMigration.java @@ -29,7 +29,7 @@ class FilePathMigration { private static final Pattern OLD_CANONICAL_FILENAME_PATTERN = Pattern.compile("(0|1S)?([A-Z2-7]{8})*[A-Z2-7=]{8}"); private static final BaseEncoding BASE32 = BaseEncoding.base32(); private static final BaseEncoding BASE64 = BaseEncoding.base64Url(); - private static final int SHORTENING_THRESHOLD = 222; // see calculations in https://github.com/cryptomator/cryptofs/issues/60 + private static final int SHORTENING_THRESHOLD = 220; // see calculations in https://github.com/cryptomator/cryptofs/issues/60 private static final String OLD_DIRECTORY_PREFIX = "0"; private static final String OLD_SYMLINK_PREFIX = "1S"; private static final String NEW_REGULAR_SUFFIX = ".c9r"; @@ -150,7 +150,6 @@ public Path migrate() throws IOException { * @param attemptSuffix Empty string or anything starting with a non base64 delimiter * @return The path after successful migration of {@link #oldPath} if migration is successful for the given attemptSuffix */ - // visible for testing Path getTargetPath(String attemptSuffix) throws InvalidOldFilenameException { final String canonicalInflatedName = getNewInflatedName(); final String canonicalDeflatedName = getNewDeflatedName(); diff --git a/src/main/java/org/cryptomator/cryptofs/migration/v7/VaultStatsVisitor.java b/src/main/java/org/cryptomator/cryptofs/migration/v7/VaultStatsVisitor.java new file mode 100644 index 00000000..7a8b4fd6 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/migration/v7/VaultStatsVisitor.java @@ -0,0 +1,75 @@ +package org.cryptomator.cryptofs.migration.v7; + +import org.cryptomator.cryptofs.common.Constants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Optional; + +public class VaultStatsVisitor extends SimpleFileVisitor { + + private static final Logger LOG = LoggerFactory.getLogger(VaultStatsVisitor.class); + + private final Path vaultRoot; + private final boolean determineMaxCiphertextPathLength; + private long fileCount = 0; + private long maxPathLength = 0; + private Path longestNewFile = null; + + public VaultStatsVisitor(Path vaultRoot, boolean determineMaxCiphertextPathLength) { + this.vaultRoot = vaultRoot; + this.determineMaxCiphertextPathLength = determineMaxCiphertextPathLength; + } + + public long getTotalFileCount() { + return fileCount; + } + + + public Path getLongestNewFile() { + return longestNewFile; + } + + public long getMaxCiphertextPathLength() { + if (determineMaxCiphertextPathLength) { + return maxPathLength; + } else { + return Constants.MAX_CIPHERTEXT_PATH_LENGTH; + } + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + fileCount++; + if (determineMaxCiphertextPathLength) { + try { + Optional migration = FilePathMigration.parse(vaultRoot, file); + migration.ifPresent(this::updateMaxCiphertextPathLength); + } catch (UninflatableFileException e) { + LOG.warn("SKIP {} because inflation failed.", file); + return FileVisitResult.CONTINUE; + } + } + return FileVisitResult.CONTINUE; + } + + private void updateMaxCiphertextPathLength(FilePathMigration filePathMigration) { + try { + Path newPath = filePathMigration.getTargetPath(""); + Path relativeToVaultRoot = vaultRoot.relativize(newPath); + int len = relativeToVaultRoot.toString().length(); + if (len > maxPathLength) { + maxPathLength = len; + longestNewFile = newPath; + } + } catch (InvalidOldFilenameException e) { + LOG.warn("Encountered malformed filename.", e); + } + } + +} diff --git a/src/main/java/org/cryptomator/cryptofs/migration/v7/Version7Migrator.java b/src/main/java/org/cryptomator/cryptofs/migration/v7/Version7Migrator.java index 01fd9c92..f8adc156 100644 --- a/src/main/java/org/cryptomator/cryptofs/migration/v7/Version7Migrator.java +++ b/src/main/java/org/cryptomator/cryptofs/migration/v7/Version7Migrator.java @@ -5,9 +5,14 @@ *******************************************************************************/ package org.cryptomator.cryptofs.migration.v7; +import org.cryptomator.cryptofs.FileNameTooLongException; +import org.cryptomator.cryptofs.common.FileSystemCapabilityChecker; import org.cryptomator.cryptofs.common.MasterkeyBackupFileHasher; import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.DeletingFileVisitor; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener.ContinuationEvent; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener.ContinuationResult; import org.cryptomator.cryptofs.migration.api.MigrationProgressListener; import org.cryptomator.cryptofs.migration.api.Migrator; import org.cryptomator.cryptolib.api.Cryptor; @@ -21,15 +26,11 @@ import javax.inject.Inject; import java.io.IOException; import java.nio.file.FileVisitOption; -import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; -import java.nio.file.attribute.BasicFileAttributes; import java.util.EnumSet; -import java.util.concurrent.atomic.LongAdder; public class Version7Migrator implements Migrator { @@ -43,7 +44,7 @@ public Version7Migrator(CryptorProvider cryptorProvider) { } @Override - public void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener) throws InvalidPassphraseException, UnsupportedVaultFormatException, IOException { + public void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passphrase, MigrationProgressListener progressListener, MigrationContinuationListener continuationListener) throws InvalidPassphraseException, UnsupportedVaultFormatException, IOException { LOG.info("Upgrading {} from version 6 to version 7.", vaultRoot); progressListener.update(MigrationProgressListener.ProgressState.INITIALIZING, 0.0); Path masterkeyFile = vaultRoot.resolve(masterkeyFilename); @@ -54,15 +55,47 @@ public void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passp Path masterkeyBackupFile = vaultRoot.resolve(masterkeyFilename + MasterkeyBackupFileHasher.generateFileIdSuffix(fileContentsBeforeUpgrade) + Constants.MASTERKEY_BACKUP_SUFFIX); Files.copy(masterkeyFile, masterkeyBackupFile, StandardCopyOption.REPLACE_EXISTING); LOG.info("Backed up masterkey from {} to {}.", masterkeyFile.getFileName(), masterkeyBackupFile.getFileName()); + + // check file system capabilities: + int pathLengthLimit = new FileSystemCapabilityChecker().determineSupportedPathLength(vaultRoot); + VaultStatsVisitor vaultStats; + if (pathLengthLimit >= Constants.MAX_CIPHERTEXT_PATH_LENGTH) { + LOG.info("Underlying file system meets path length requirements."); + vaultStats = new VaultStatsVisitor(vaultRoot, false); + } else { + LOG.warn("Underlying file system only supports paths with up to {} chars (required: {}). Asking for user feedback...", pathLengthLimit, Constants.MAX_CIPHERTEXT_PATH_LENGTH); + ContinuationResult result = continuationListener.continueMigrationOnEvent(ContinuationEvent.REQUIRES_FULL_VAULT_DIR_SCAN); + switch (result) { + case PROCEED: + vaultStats = new VaultStatsVisitor(vaultRoot, true); + break; + case CANCEL: + LOG.info("Migration canceled by user."); + return; + default: + throw new IllegalStateException("Unexpected result " + result); + } + } - long toBeMigrated = countFileNames(vaultRoot); + // dry-run to collect stats: + Path dataDir = vaultRoot.resolve("d"); + Files.walkFileTree(dataDir, EnumSet.noneOf(FileVisitOption.class), 3, vaultStats); + + // fail if file names are too long: + if (vaultStats.getMaxCiphertextPathLength() > pathLengthLimit) { + LOG.error("Migration aborted due to lacking capabilities of underlying file system. Vault is unchanged."); + throw new FileNameTooLongException(vaultStats.getLongestNewFile().toString(), pathLengthLimit); + } + + // start migration: + long toBeMigrated = vaultStats.getTotalFileCount(); + LOG.info("Starting migration of {} files", toBeMigrated); if (toBeMigrated > 0) { migrateFileNames(vaultRoot, progressListener, toBeMigrated); } + // cleanup: progressListener.update(MigrationProgressListener.ProgressState.FINALIZING, 0.0); - - // remove deprecated /m/ directory Files.walkFileTree(vaultRoot.resolve("m"), DeletingFileVisitor.INSTANCE); // rewrite masterkey file with normalized passphrase: @@ -72,20 +105,6 @@ public void migrate(Path vaultRoot, String masterkeyFilename, CharSequence passp } LOG.info("Upgraded {} from version 6 to version 7.", vaultRoot); } - - private long countFileNames(Path vaultRoot) throws IOException { - LongAdder counter = new LongAdder(); - Path dataDir = vaultRoot.resolve("d"); - Files.walkFileTree(dataDir, EnumSet.noneOf(FileVisitOption.class), 3, new SimpleFileVisitor() { - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - counter.increment(); - return FileVisitResult.CONTINUE; - } - }); - return counter.sum(); - } private void migrateFileNames(Path vaultRoot, MigrationProgressListener progressListener, long totalFiles) throws IOException { assert totalFiles > 0; diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index f75e436d..2aa87113 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -100,6 +100,7 @@ public class CryptoFileSystemImplTest { private final FinallyUtil finallyUtil = mock(FinallyUtil.class); private final CiphertextDirectoryDeleter ciphertextDirDeleter = mock(CiphertextDirectoryDeleter.class); private final ReadonlyFlag readonlyFlag = mock(ReadonlyFlag.class); + private final CryptoFileSystemProperties fileSystemProperties = mock(CryptoFileSystemProperties.class); private final CryptoPath root = mock(CryptoPath.class); private final CryptoPath empty = mock(CryptoPath.class); @@ -110,12 +111,18 @@ public class CryptoFileSystemImplTest { public void setup() { when(cryptoPathFactory.rootFor(any())).thenReturn(root); when(cryptoPathFactory.emptyFor(any())).thenReturn(empty); + when(pathToVault.relativize(Mockito.any(Path.class))).then(invocation -> { + Path other = invocation.getArgument(0); + return other; + }); + when(fileSystemProperties.maxPathLength()).thenReturn(Integer.MAX_VALUE); inTest = new CryptoFileSystemImpl(provider, cryptoFileSystems, pathToVault, cryptor, fileStore, stats, cryptoPathMapper, cryptoPathFactory, pathMatcherFactory, directoryStreamFactory, dirIdProvider, fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, - openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, rootDirectoryInitializer); + openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, + fileSystemProperties, rootDirectoryInitializer); } @Test diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java index 179fcb86..7bb508a8 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java @@ -15,9 +15,11 @@ import java.util.Map.Entry; import static org.cryptomator.cryptofs.CryptoFileSystemProperties.DEFAULT_MASTERKEY_FILENAME; +import static org.cryptomator.cryptofs.CryptoFileSystemProperties.DEFAULT_MAX_PATH_LENGTH; import static org.cryptomator.cryptofs.CryptoFileSystemProperties.DEFAULT_PEPPER; import static org.cryptomator.cryptofs.CryptoFileSystemProperties.PROPERTY_FILESYSTEM_FLAGS; import static org.cryptomator.cryptofs.CryptoFileSystemProperties.PROPERTY_MASTERKEY_FILENAME; +import static org.cryptomator.cryptofs.CryptoFileSystemProperties.PROPERTY_MAX_PATH_LENGTH; import static org.cryptomator.cryptofs.CryptoFileSystemProperties.PROPERTY_PASSPHRASE; import static org.cryptomator.cryptofs.CryptoFileSystemProperties.PROPERTY_PEPPER; import static org.cryptomator.cryptofs.CryptoFileSystemProperties.cryptoFileSystemProperties; @@ -53,6 +55,7 @@ public void testSetOnlyPassphrase() { anEntry(PROPERTY_PASSPHRASE, passphrase), // anEntry(PROPERTY_PEPPER, DEFAULT_PEPPER), // anEntry(PROPERTY_MASTERKEY_FILENAME, DEFAULT_MASTERKEY_FILENAME), // + anEntry(PROPERTY_MAX_PATH_LENGTH, DEFAULT_MAX_PATH_LENGTH), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.INIT_IMPLICITLY, FileSystemFlags.MIGRATE_IMPLICITLY)))); } @@ -75,6 +78,7 @@ public void testSetPassphraseAndReadonlyFlag() { anEntry(PROPERTY_PASSPHRASE, passphrase), // anEntry(PROPERTY_PEPPER, DEFAULT_PEPPER), // anEntry(PROPERTY_MASTERKEY_FILENAME, DEFAULT_MASTERKEY_FILENAME), // + anEntry(PROPERTY_MAX_PATH_LENGTH, DEFAULT_MAX_PATH_LENGTH), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); } @@ -99,6 +103,7 @@ public void testSetPassphraseAndMasterkeyFilenameAndReadonlyFlag() { anEntry(PROPERTY_PASSPHRASE, passphrase), // anEntry(PROPERTY_PEPPER, DEFAULT_PEPPER), // anEntry(PROPERTY_MASTERKEY_FILENAME, masterkeyFilename), // + anEntry(PROPERTY_MAX_PATH_LENGTH, DEFAULT_MAX_PATH_LENGTH), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); } @@ -112,6 +117,7 @@ public void testFromMap() { map.put(PROPERTY_PASSPHRASE, passphrase); map.put(PROPERTY_PEPPER, pepper); map.put(PROPERTY_MASTERKEY_FILENAME, masterkeyFilename); + map.put(PROPERTY_MAX_PATH_LENGTH, 255); map.put(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)); CryptoFileSystemProperties inTest = cryptoFileSystemPropertiesFrom(map).build(); @@ -124,6 +130,7 @@ public void testFromMap() { anEntry(PROPERTY_PASSPHRASE, passphrase), // anEntry(PROPERTY_PEPPER, pepper), // anEntry(PROPERTY_MASTERKEY_FILENAME, masterkeyFilename), // + anEntry(PROPERTY_MAX_PATH_LENGTH, 255), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); } @@ -149,6 +156,7 @@ public void testWrapMapWithTrueReadonly() { anEntry(PROPERTY_PASSPHRASE, passphrase), // anEntry(PROPERTY_PEPPER, pepper), // anEntry(PROPERTY_MASTERKEY_FILENAME, masterkeyFilename), // + anEntry(PROPERTY_MAX_PATH_LENGTH, DEFAULT_MAX_PATH_LENGTH), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); } @@ -174,6 +182,7 @@ public void testWrapMapWithFalseReadonly() { anEntry(PROPERTY_PASSPHRASE, passphrase), // anEntry(PROPERTY_PEPPER, pepper), // anEntry(PROPERTY_MASTERKEY_FILENAME, masterkeyFilename), // + anEntry(PROPERTY_MAX_PATH_LENGTH, DEFAULT_MAX_PATH_LENGTH), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)))); } @@ -233,6 +242,7 @@ public void testWrapMapWithoutReadonly() { anEntry(PROPERTY_PASSPHRASE, passphrase), // anEntry(PROPERTY_PEPPER, pepper), // anEntry(PROPERTY_MASTERKEY_FILENAME, DEFAULT_MASTERKEY_FILENAME), // + anEntry(PROPERTY_MAX_PATH_LENGTH, DEFAULT_MAX_PATH_LENGTH), // anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.INIT_IMPLICITLY, FileSystemFlags.MIGRATE_IMPLICITLY)))); } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemProviderIntegrationTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemProviderIntegrationTest.java index 9dd4411b..571bcdff 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemProviderIntegrationTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemProviderIntegrationTest.java @@ -33,6 +33,7 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import java.io.IOException; import java.net.URI; @@ -47,11 +48,14 @@ import java.nio.channels.WritableByteChannel; import java.nio.charset.StandardCharsets; import java.nio.file.AccessDeniedException; +import java.nio.file.CopyOption; import java.nio.file.FileSystem; import java.nio.file.FileSystems; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.BasicFileAttributes; @@ -66,6 +70,99 @@ public class CryptoFileSystemProviderIntegrationTest { + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class WithLimitedPaths { + + private CryptoFileSystem fs; + private Path shortFilePath; + private Path shortSymlinkPath; + private Path shortDirPath; + + @BeforeAll + public void setup(@TempDir Path tmpDir) throws IOException { + CryptoFileSystemProvider.initialize(tmpDir, "masterkey.cryptomator", "asd"); + CryptoFileSystemProperties properties = cryptoFileSystemProperties() // + .withFlags() // + .withMasterkeyFilename("masterkey.cryptomator") // + .withPassphrase("asd") // + .withMaxPathLength(100) + .build(); + fs = CryptoFileSystemProvider.newFileSystem(tmpDir, properties); + } + + @BeforeEach + public void setupEach() throws IOException { + shortFilePath = fs.getPath("/short-enough.txt"); + shortDirPath = fs.getPath("/short-enough-dir"); + shortSymlinkPath = fs.getPath("/symlink.txt"); + Files.createFile(shortFilePath); + Files.createDirectory(shortDirPath); + Files.createSymbolicLink(shortSymlinkPath, shortFilePath); + } + + @AfterEach + public void tearDownEach() throws IOException { + Files.deleteIfExists(shortFilePath); + Files.deleteIfExists(shortDirPath); + Files.deleteIfExists(shortSymlinkPath); + } + + @DisplayName("expect create file to fail with FileNameTooLongException") + @Test + public void testCreateFileExceedingPathLengthLimit() { + Path p = fs.getPath("/this-should-result-in-ciphertext-path-longer-than-100"); + Assertions.assertThrows(FileNameTooLongException.class, () -> { + Files.createFile(p); + }); + } + + @DisplayName("expect create directory to fail with FileNameTooLongException") + @Test + public void testCreateDirExceedingPathLengthLimit() { + Path p = fs.getPath("/this-should-result-in-ciphertext-path-longer-than-100"); + Assertions.assertThrows(FileNameTooLongException.class, () -> { + Files.createDirectory(p); + }); + } + + @DisplayName("expect create symlink to fail with FileNameTooLongException") + @Test + public void testCreateSymlinkExceedingPathLengthLimit() { + Path p = fs.getPath("/this-should-result-in-ciphertext-path-longer-than-100"); + Assertions.assertThrows(FileNameTooLongException.class, () -> { + Files.createSymbolicLink(p, shortFilePath); + }); + } + + @DisplayName("expect move to fail with FileNameTooLongException") + @ParameterizedTest(name = "move {0} -> this-should-result-in-ciphertext-path-longer-than-100") + @ValueSource(strings = {"/short-enough.txt", "/short-enough-dir", "/symlink.txt"}) + public void testMoveExceedingPathLengthLimit(String path) { + Path src = fs.getPath(path); + Path dst = fs.getPath("/this-should-result-in-ciphertext-path-longer-than-100"); + Assertions.assertThrows(FileNameTooLongException.class, () -> { + Files.move(src, dst); + }); + Assertions.assertTrue(Files.exists(src)); + Assertions.assertTrue(Files.notExists(dst)); + } + + @DisplayName("expect copy to fail with FileNameTooLongException") + @ParameterizedTest(name = "copy {0} -> this-should-result-in-ciphertext-path-longer-than-100") + @ValueSource(strings = {"/short-enough.txt", "/short-enough-dir", "/symlink.txt"}) + public void testCopyExceedingPathLengthLimit(String path) { + Path src = fs.getPath(path); + Path dst = fs.getPath("/this-should-result-in-ciphertext-path-longer-than-100"); + Assertions.assertThrows(FileNameTooLongException.class, () -> { + Files.copy(src, dst, LinkOption.NOFOLLOW_LINKS); + }); + Assertions.assertTrue(Files.exists(src)); + Assertions.assertTrue(Files.notExists(dst)); + } + + } + @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -230,7 +327,7 @@ public void testCreateSymlink() throws IOException { } @Test - @Order(6) + @Order(7) @DisplayName("echo 'hello world' > /link") public void testWriteToSymlink() throws IOException { Path link = fs1.getPath("/link"); @@ -258,6 +355,19 @@ public void testReadFromSymlink() throws IOException { } } + @Test + @Order(7) + @DisplayName("cp /link /otherlink") + public void testCopySymlinkSymlink() throws IOException { + Path src = fs1.getPath("/link"); + Path dst = fs1.getPath("/otherlink"); + Assumptions.assumeTrue(Files.isSymbolicLink(src)); + Assumptions.assumeTrue(Files.notExists(dst)); + Files.copy(src, dst, LinkOption.NOFOLLOW_LINKS); + Assertions.assertTrue(Files.isSymbolicLink(src)); + Assertions.assertTrue(Files.isSymbolicLink(dst)); + } + @Test @Order(8) @DisplayName("rm /link") @@ -267,6 +377,15 @@ public void testRemoveSymlink() throws IOException { Files.delete(link); } + @Test + @Order(8) + @DisplayName("rm /otherlink") + public void testRemoveOtherSymlink() throws IOException { + Path link = fs1.getPath("/otherlink"); + Assumptions.assumeTrue(Files.isSymbolicLink(link)); + Files.delete(link); + } + @Test @Order(9) @DisplayName("mkdir '/Internet Telefon Energie Wasser Webseitengeraffel Bus Bahn Mietwagen Internet Telefon Energie Wasser Webseitengeraffel Bus Bahn Mietwagen Internet'") diff --git a/src/test/java/org/cryptomator/cryptofs/common/FileSystemCapabilityCheckerTest.java b/src/test/java/org/cryptomator/cryptofs/common/FileSystemCapabilityCheckerTest.java new file mode 100644 index 00000000..99c56ef9 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/common/FileSystemCapabilityCheckerTest.java @@ -0,0 +1,121 @@ +package org.cryptomator.cryptofs.common; + +import org.cryptomator.cryptofs.mocks.DirectoryStreamMock; +import org.cryptomator.cryptofs.mocks.SeekableByteChannelMock; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.DirectoryIteratorException; +import java.nio.file.FileSystem; +import java.nio.file.Path; +import java.nio.file.spi.FileSystemProvider; +import java.util.Collections; + +class FileSystemCapabilityCheckerTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class PathLengthLimits { + + private Path pathToVault = Mockito.mock(Path.class); + private FileSystem fileSystem = Mockito.mock(FileSystem.class); + private FileSystemProvider fileSystemProvider = Mockito.mock(FileSystemProvider.class); + + @BeforeAll + public void setup() { + Mockito.when(pathToVault.getFileSystem()).thenReturn(fileSystem); + Mockito.when(fileSystem.provider()).thenReturn(fileSystemProvider); + } + + @Test + public void testDetermineSupportedPathLengthWithUnlimitedPathLength() { + Mockito.when(pathToVault.resolve(Mockito.anyString())).then(invocation -> { + String checkDirStr = invocation.getArgument(0); + Path checkDirMock = Mockito.mock(Path.class, checkDirStr); + Mockito.when(checkDirMock.getFileSystem()).thenReturn(fileSystem); + Mockito.when(checkDirMock.resolve(Mockito.anyString())).then(invocation2 -> { + String checkFileStr = invocation.getArgument(0); + Path checkFileMock = Mockito.mock(Path.class, checkFileStr); + Mockito.when(checkFileMock.getFileSystem()).thenReturn(fileSystem); + Mockito.when(fileSystemProvider.newByteChannel(Mockito.eq(checkFileMock), Mockito.any())) + .thenReturn(new SeekableByteChannelMock(0)); + return checkFileMock; + }); + Mockito.when(fileSystemProvider.newDirectoryStream(Mockito.eq(checkDirMock), Mockito.any())).thenReturn(DirectoryStreamMock.empty()); + return checkDirMock; + }); + + int determinedLimit = new FileSystemCapabilityChecker().determineSupportedPathLength(pathToVault); + + Assertions.assertEquals(268, determinedLimit); + } + + @Test + public void testDetermineSupportedPathLengthWithLimitedPathLength() { + int limit = 255; + Mockito.when(pathToVault.resolve(Mockito.anyString())).then(invocation -> { + String checkDirStr = invocation.getArgument(0); + Path checkDirMock = Mockito.mock(Path.class, checkDirStr); + Mockito.when(checkDirMock.getFileSystem()).thenReturn(fileSystem); + Mockito.when(checkDirMock.resolve(Mockito.anyString())).then(invocation2 -> { + String checkFileStr = invocation.getArgument(0); + Path checkFileMock = Mockito.mock(Path.class, checkFileStr); + Mockito.when(checkFileMock.getFileSystem()).thenReturn(fileSystem); + Mockito.when(fileSystemProvider.newByteChannel(Mockito.eq(checkFileMock), Mockito.any())) + .thenReturn(new SeekableByteChannelMock(0)); + return checkFileMock; + }); + Mockito.when(fileSystemProvider.newDirectoryStream(Mockito.eq(checkDirMock), Mockito.any())).then(invocation3 -> { + Iterable iterable = Mockito.mock(Iterable.class); + if (Integer.valueOf(checkDirStr.substring(44, 47)) > limit) { + Mockito.when(iterable.iterator()).thenThrow(new DirectoryIteratorException(new IOException("path too long"))); + } else { + Mockito.when(iterable.iterator()).thenReturn(Collections.emptyIterator()); + } + return DirectoryStreamMock.withElementsFrom(iterable); + }); + return checkDirMock; + }); + + int determinedLimit = new FileSystemCapabilityChecker().determineSupportedPathLength(pathToVault); + + Assertions.assertEquals(limit, determinedLimit); + } + + @Test + public void testDetermineSupportedPathLengthWithLimitedNameLength() { + int limit = 150; + Mockito.when(pathToVault.resolve(Mockito.anyString())).then(invocation -> { + String checkDirStr = invocation.getArgument(0); + Path checkDirMock = Mockito.mock(Path.class, checkDirStr); + Mockito.when(checkDirMock.getFileSystem()).thenReturn(fileSystem); + Mockito.when(checkDirMock.resolve(Mockito.anyString())).then(invocation2 -> { + String checkFileStr = invocation.getArgument(0); + Path checkFileMock = Mockito.mock(Path.class, checkFileStr); + Mockito.when(checkFileMock.getFileSystem()).thenReturn(fileSystem); + if (Integer.valueOf(checkDirStr.substring(44, 47)) > limit) { + Mockito.when(fileSystemProvider.newByteChannel(Mockito.eq(checkFileMock), Mockito.any())) + .thenThrow(new IOException("name too long")); + } else { + Mockito.when(fileSystemProvider.newByteChannel(Mockito.eq(checkFileMock), Mockito.any())) + .thenReturn(new SeekableByteChannelMock(0)); + } + return checkFileMock; + }); + Mockito.when(fileSystemProvider.newDirectoryStream(Mockito.eq(checkDirMock), Mockito.any())).thenReturn(DirectoryStreamMock.empty()); + return checkDirMock; + }); + + int determinedLimit = new FileSystemCapabilityChecker().determineSupportedPathLength(pathToVault); + + Assertions.assertEquals(limit, determinedLimit); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptofs/migration/MigratorsTest.java b/src/test/java/org/cryptomator/cryptofs/migration/MigratorsTest.java index 6a90919d..e4391c22 100644 --- a/src/test/java/org/cryptomator/cryptofs/migration/MigratorsTest.java +++ b/src/test/java/org/cryptomator/cryptofs/migration/MigratorsTest.java @@ -6,6 +6,8 @@ package org.cryptomator.cryptofs.migration; import org.cryptomator.cryptofs.common.FileSystemCapabilityChecker; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener.ContinuationResult; import org.cryptomator.cryptofs.migration.api.MigrationProgressListener; import org.cryptomator.cryptofs.migration.api.Migrator; import org.cryptomator.cryptofs.migration.api.NoApplicableMigratorException; @@ -80,35 +82,23 @@ public void testNeedsNoMigration() throws IOException { public void testMigrateWithoutMigrators() throws IOException { Migrators migrators = new Migrators(Collections.emptyMap(), fsCapabilityChecker); Assertions.assertThrows(NoApplicableMigratorException.class, () -> { - migrators.migrate(pathToVault, "masterkey.cryptomator", "secret", (state, progress) -> {}); + migrators.migrate(pathToVault, "masterkey.cryptomator", "secret", (state, progress) -> {}, (event) -> ContinuationResult.CANCEL); }); } - - @Test - public void testMigrateWithFailingCapabilitiesCheck() throws IOException { - Migrators migrators = new Migrators(Collections.emptyMap(), fsCapabilityChecker); - - Exception expected = new FileSystemCapabilityChecker.MissingCapabilityException(pathToVault, FileSystemCapabilityChecker.Capability.LONG_FILENAMES); - Mockito.doThrow(expected).when(fsCapabilityChecker).assertAllCapabilities(pathToVault); - - Exception thrown = Assertions.assertThrows(FileSystemCapabilityChecker.MissingCapabilityException.class, () -> { - migrators.migrate(pathToVault, "masterkey.cryptomator", "secret", (state, progress) -> {}); - }); - Assertions.assertEquals(expected, thrown); - } - + @Test @SuppressWarnings("deprecation") public void testMigrate() throws NoApplicableMigratorException, InvalidPassphraseException, IOException { - MigrationProgressListener listener = Mockito.mock(MigrationProgressListener.class); + MigrationProgressListener progressListener = Mockito.mock(MigrationProgressListener.class); + MigrationContinuationListener continuationListener = Mockito.mock(MigrationContinuationListener.class); Migrator migrator = Mockito.mock(Migrator.class); Migrators migrators = new Migrators(new HashMap() { { put(Migration.ZERO_TO_ONE, migrator); } }, fsCapabilityChecker); - migrators.migrate(pathToVault, "masterkey.cryptomator", "secret", listener); - Mockito.verify(migrator).migrate(pathToVault, "masterkey.cryptomator", "secret", listener); + migrators.migrate(pathToVault, "masterkey.cryptomator", "secret", progressListener, continuationListener); + Mockito.verify(migrator).migrate(pathToVault, "masterkey.cryptomator", "secret", progressListener, continuationListener); } @Test @@ -120,9 +110,9 @@ public void testMigrateUnsupportedVaultFormat() throws NoApplicableMigratorExcep put(Migration.ZERO_TO_ONE, migrator); } }, fsCapabilityChecker); - Mockito.doThrow(new UnsupportedVaultFormatException(Integer.MAX_VALUE, 1)).when(migrator).migrate(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.doThrow(new UnsupportedVaultFormatException(Integer.MAX_VALUE, 1)).when(migrator).migrate(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()); Assertions.assertThrows(IllegalStateException.class, () -> { - migrators.migrate(pathToVault, "masterkey.cryptomator", "secret", (state, progress) -> {}); + migrators.migrate(pathToVault, "masterkey.cryptomator", "secret", (state, progress) -> {}, (event) -> ContinuationResult.CANCEL); }); } diff --git a/src/test/java/org/cryptomator/cryptofs/migration/api/SimpleMigrationContinuationListenerTest.java b/src/test/java/org/cryptomator/cryptofs/migration/api/SimpleMigrationContinuationListenerTest.java new file mode 100644 index 00000000..3e2738d9 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/migration/api/SimpleMigrationContinuationListenerTest.java @@ -0,0 +1,41 @@ +package org.cryptomator.cryptofs.migration.api; + +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener.ContinuationEvent; +import org.cryptomator.cryptofs.migration.api.MigrationContinuationListener.ContinuationResult; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +class SimpleMigrationContinuationListenerTest { + + @Test + public void testConcurrency() { + ExecutorService threadPool = Executors.newCachedThreadPool(); + + SimpleMigrationContinuationListener inTest = new SimpleMigrationContinuationListener() { + @Override + public void migrationHaltedDueToEvent(ContinuationEvent event) { + // receive event on background thread that runs migration: + System.out.println("received event on " + Thread.currentThread().getName()); + + threadPool.submit(() -> { + // choose PROCEED on different thread (like from UI events): + System.out.println("choosing PROCEED on thread " + Thread.currentThread().getName()); + this.continueMigrationWithResult(ContinuationResult.PROCEED); + }); + } + }; + + Assertions.assertTimeoutPreemptively(Duration.ofSeconds(10), () -> { // deadlock protection + ContinuationResult result = inTest.continueMigrationOnEvent(ContinuationEvent.REQUIRES_FULL_VAULT_DIR_SCAN); + System.out.println("received result " + result + " on " + Thread.currentThread().getName()); + Assertions.assertEquals(ContinuationResult.PROCEED, result); + }); + + threadPool.shutdown(); + } + +} \ No newline at end of file