diff --git a/.github/workflows/publish-github.yml b/.github/workflows/publish-github.yml index 32a3041..8be609b 100644 --- a/.github/workflows/publish-github.yml +++ b/.github/workflows/publish-github.yml @@ -35,6 +35,6 @@ jobs: SLACK_ICON_EMOJI: ':bot:' SLACK_CHANNEL: 'cryptomator-desktop' SLACK_TITLE: "Published ${{ github.event.repository.name }} ${{ github.event.release.tag_name }}" - SLACK_MESSAGE: "Ready to ." + SLACK_MESSAGE: "Ready to ." SLACK_FOOTER: - MSG_MINIMAL: true \ No newline at end of file + MSG_MINIMAL: true diff --git a/README.md b/README.md index d0a660d..85430b5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Build Status](https://github.com/cryptomator/fuse-nio-adapter/workflows/Build/badge.svg)](https://github.com/cryptomator/fuse-nio-adapter/actions?query=workflow%3ABuild) -[![Codacy Code Quality](https://app.codacy.com/project/badge/Grade/47914e82b4c54f39b6150c24b83d7d09)](https://www.codacy.com/gh/cryptomator/fuse-nio-adapter) -[![Codacy Coverage](https://app.codacy.com/project/badge/Coverage/47914e82b4c54f39b6150c24b83d7d09)](https://www.codacy.com/gh/cryptomator/fuse-nio-adapter) +[![Codacy Code Quality](https://app.codacy.com/project/badge/Grade/47914e82b4c54f39b6150c24b83d7d09)](https://www.codacy.com/gh/cryptomator/fuse-nio-adapter/dashboard) +[![Codacy Coverage](https://app.codacy.com/project/badge/Coverage/47914e82b4c54f39b6150c24b83d7d09)](https://www.codacy.com/gh/cryptomator/fuse-nio-adapter/dashboard) [![Known Vulnerabilities](https://snyk.io/test/github/cryptomator/fuse-nio-adapter/badge.svg)](https://snyk.io/test/github/cryptomator/fuse-nio-adapter) # fuse-nio-adapter diff --git a/pom.xml b/pom.xml index 6640536..635593c 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.cryptomator fuse-nio-adapter - 1.2.9 + 1.3.0 FUSE-NIO-Adapter Access resources at a given NIO path via FUSE. https://github.com/cryptomator/fuse-nio-adapter @@ -183,7 +183,7 @@ org.owasp dependency-check-maven - 6.1.0 + 6.1.2 24 0 diff --git a/src/main/java/org/cryptomator/frontend/fuse/AdapterFactory.java b/src/main/java/org/cryptomator/frontend/fuse/AdapterFactory.java index 5fa7795..3ff9d88 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/AdapterFactory.java +++ b/src/main/java/org/cryptomator/frontend/fuse/AdapterFactory.java @@ -4,26 +4,58 @@ public class AdapterFactory { - private static final int DEFAULT_NAME_MAX = 254; // 255 is preferred, but nautilus checks for this value + 1 + /** + * The default value for the maximum supported filename length. + */ + public static final int DEFAULT_MAX_FILENAMELENGTH = 254; // 255 is preferred, but nautilus checks for this value + 1 private AdapterFactory() { } + /** + * Creates a read-only fuse-nio filesystem with a maximum file name length of {@value DEFAULT_MAX_FILENAMELENGTH} and an assumed filename encoding of UTF-8 NFC for FUSE and the NIO filesystem. + * @param root the root path of the NIO filesystem. + * @return an adapter mapping FUSE callbacks to the nio interface + * @see ReadOnlyAdapter + * @see FileNameTranscoder + */ public static FuseNioAdapter createReadOnlyAdapter(Path root) { - return createReadOnlyAdapter(root, DEFAULT_NAME_MAX); + return createReadOnlyAdapter(root, DEFAULT_MAX_FILENAMELENGTH, FileNameTranscoder.transcoder() ); } - public static FuseNioAdapter createReadOnlyAdapter(Path root, int maxFileNameLength) { - FuseNioAdapterComponent comp = DaggerFuseNioAdapterComponent.builder().root(root).maxFileNameLength(maxFileNameLength).build(); + public static FuseNioAdapter createReadOnlyAdapter(Path root, int maxFileNameLength, FileNameTranscoder fileNameTranscoder) { + FuseNioAdapterComponent comp = DaggerFuseNioAdapterComponent.builder() + .root(root) + .maxFileNameLength(maxFileNameLength) + .fileNameTranscoder(fileNameTranscoder) + .build(); return comp.readOnlyAdapter(); } + /** + * Creates a fuse-nio-filesystem with a maximum file name length of {@value DEFAULT_MAX_FILENAMELENGTH} and an assumed filename encoding of UTF-8 NFC for FUSE and the NIO filesystem. + * @param root the root path of the NIO filesystem. + * @return an adapter mapping FUSE callbacks to the nio interface + * @see ReadWriteAdapter + * @see FileNameTranscoder + */ public static FuseNioAdapter createReadWriteAdapter(Path root) { - return createReadWriteAdapter(root, DEFAULT_NAME_MAX); + return createReadWriteAdapter(root, DEFAULT_MAX_FILENAMELENGTH); } + /** + * Creates a fuse-nio-filesystem with an assumed filename encoding of UTF-8 NFC for FUSE and the NIO filesystem. + * @param root the root path of the NIO filesystem. + * @return an adapter mapping FUSE callbacks to the nio interface + * @see ReadWriteAdapter + * @see FileNameTranscoder + */ public static FuseNioAdapter createReadWriteAdapter(Path root, int maxFileNameLength) { - FuseNioAdapterComponent comp = DaggerFuseNioAdapterComponent.builder().root(root).maxFileNameLength(maxFileNameLength).build(); + return createReadWriteAdapter(root,maxFileNameLength,FileNameTranscoder.transcoder()); + } + + public static FuseNioAdapter createReadWriteAdapter(Path root, int maxFileNameLength, FileNameTranscoder fileNameTranscoder) { + FuseNioAdapterComponent comp = DaggerFuseNioAdapterComponent.builder().root(root).maxFileNameLength(maxFileNameLength).fileNameTranscoder(fileNameTranscoder).build(); return comp.readWriteAdapter(); } } diff --git a/src/main/java/org/cryptomator/frontend/fuse/FileNameTranscoder.java b/src/main/java/org/cryptomator/frontend/fuse/FileNameTranscoder.java new file mode 100644 index 0000000..de29d29 --- /dev/null +++ b/src/main/java/org/cryptomator/frontend/fuse/FileNameTranscoder.java @@ -0,0 +1,107 @@ +package org.cryptomator.frontend.fuse; + +import com.google.common.base.Preconditions; + +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; + +/** + * Class to transcode filenames and path components from one encoding to another. + *

+ * Instances created with {@link FileNameTranscoder#transcoder()} default to fuse and nio UTF-8 encoding with NFC normalization. To change encoding and normalization, use the supplied "withXXX()" methods. If an encoding is not part of the UTF famlily, the normalization is ignored. + */ +public class FileNameTranscoder { + + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + private static final Normalizer.Form DEFAULT_NORMALIZATION = Normalizer.Form.NFC; + + private final Charset fuseCharset; + private final Charset nioCharset; + private final Normalizer.Form fuseNormalization; + private final Normalizer.Form nioNormalization; + private final boolean fuseCharsetIsUnicode; + private final boolean nioCharsetIsUnicode; + + FileNameTranscoder(Charset fuseCharset, Charset nioCharset, Normalizer.Form fuseNormalization, Normalizer.Form nioNormalization) { + this.fuseCharset = Preconditions.checkNotNull(fuseCharset); + this.nioCharset = Preconditions.checkNotNull(nioCharset); + this.fuseNormalization = Preconditions.checkNotNull(fuseNormalization); + this.nioNormalization = Preconditions.checkNotNull(nioNormalization); + this.fuseCharsetIsUnicode = fuseCharset.name().startsWith("UTF"); + this.nioCharsetIsUnicode = nioCharset.name().startsWith("UTF"); + } + + /** + * Transcodes the given NIO file name to FUSE representation. + * + * @param nioFileName A file name encoded with the charset used in NIO + * @return The file name encoded with the charset used by FUSE + */ + public String nioToFuse(String nioFileName) { + return transcode(nioFileName, nioCharset, fuseCharset, nioNormalization, fuseNormalization, fuseCharsetIsUnicode); + } + + /** + * Transcodes the given FUSE file name to NIO representation. + * + * @param fuseFileName A file name encoded with the charset used in FUSE + * @return The file name encoded with the charset used by NIO + */ + public String fuseToNio(String fuseFileName) { + return transcode(fuseFileName, fuseCharset, nioCharset, fuseNormalization, nioNormalization, nioCharsetIsUnicode); + } + + /** + * Interprets the given string as FUSE character set encoded and returns the original byte sequence. + * + * @param fuseFileName string from the fuse layer + * @return A byte sequence with the original encoding of the input + */ + public ByteBuffer interpretAsFuseString(String fuseFileName) { + return fuseCharset.encode(fuseFileName); + } + + /** + * Interprets the given string as NIO character set encoded and returns the original byte sequence. + * + * @param nioFileName string from the nio layer + * @return A byte sequence with the original encoding of the input + */ + public ByteBuffer interpretAsNioString(String nioFileName) { + return nioCharset.encode(nioFileName); + } + + private String transcode(String original, Charset srcCharset, Charset dstCharset, Normalizer.Form srcNormalization, Normalizer.Form dstNormalization, boolean applyNormalization) { + String result = original; + if (!srcCharset.equals(dstCharset)) { + result = dstCharset.decode(srcCharset.encode(result)).toString(); + } + if (applyNormalization && srcNormalization != dstNormalization) { + result = Normalizer.normalize(result, dstNormalization); + } + return result; + } + + /* Builder/Wither */ + public FileNameTranscoder withFuseCharset(Charset fuseCharset) { + return new FileNameTranscoder(fuseCharset, nioCharset, fuseNormalization, nioNormalization); + } + + public FileNameTranscoder withNioCharset(Charset nioCharset) { + return new FileNameTranscoder(fuseCharset, nioCharset, fuseNormalization, nioNormalization); + } + + public FileNameTranscoder withFuseNormalization(Normalizer.Form fuseNormalization) { + return new FileNameTranscoder(fuseCharset, nioCharset, fuseNormalization, nioNormalization); + } + + public FileNameTranscoder withNioNormalization(Normalizer.Form nioNormalization) { + return new FileNameTranscoder(fuseCharset, nioCharset, fuseNormalization, nioNormalization); + } + + public static FileNameTranscoder transcoder() { + return new FileNameTranscoder(DEFAULT_CHARSET, DEFAULT_CHARSET, DEFAULT_NORMALIZATION, DEFAULT_NORMALIZATION); + } +} diff --git a/src/main/java/org/cryptomator/frontend/fuse/FuseNioAdapterComponent.java b/src/main/java/org/cryptomator/frontend/fuse/FuseNioAdapterComponent.java index cb498e2..ee4a1dd 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/FuseNioAdapterComponent.java +++ b/src/main/java/org/cryptomator/frontend/fuse/FuseNioAdapterComponent.java @@ -23,6 +23,9 @@ interface Builder { @BindsInstance Builder maxFileNameLength(@Named("maxFileNameLength") int maxFileNameLength); + @BindsInstance + Builder fileNameTranscoder(FileNameTranscoder fileNameTranscoder); + FuseNioAdapterComponent build(); } diff --git a/src/main/java/org/cryptomator/frontend/fuse/ReadOnlyAdapter.java b/src/main/java/org/cryptomator/frontend/fuse/ReadOnlyAdapter.java index ca41c69..d869499 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/ReadOnlyAdapter.java +++ b/src/main/java/org/cryptomator/frontend/fuse/ReadOnlyAdapter.java @@ -53,6 +53,7 @@ public class ReadOnlyAdapter extends FuseStubFS implements FuseNioAdapter { private final int maxFileNameLength; protected final FileStore fileStore; protected final LockManager lockManager; + protected final FileNameTranscoder fileNameTranscoder; private final ReadOnlyDirectoryHandler dirHandler; private final ReadOnlyFileHandler fileHandler; private final ReadOnlyLinkHandler linkHandler; @@ -60,9 +61,10 @@ public class ReadOnlyAdapter extends FuseStubFS implements FuseNioAdapter { private final BooleanSupplier hasOpenFiles; @Inject - public ReadOnlyAdapter(@Named("root") Path root, @Named("maxFileNameLength") int maxFileNameLength, FileStore fileStore, LockManager lockManager, ReadOnlyDirectoryHandler dirHandler, ReadOnlyFileHandler fileHandler, ReadOnlyLinkHandler linkHandler, FileAttributesUtil attrUtil, OpenFileFactory fileFactory) { + public ReadOnlyAdapter(@Named("root") Path root, @Named("maxFileNameLength") int maxFileNameLength, FileNameTranscoder fileNameTranscoder, FileStore fileStore, LockManager lockManager, ReadOnlyDirectoryHandler dirHandler, ReadOnlyFileHandler fileHandler, ReadOnlyLinkHandler linkHandler, FileAttributesUtil attrUtil, OpenFileFactory fileFactory) { this.root = root; this.maxFileNameLength = maxFileNameLength; + this.fileNameTranscoder = fileNameTranscoder; this.fileStore = fileStore; this.lockManager = lockManager; this.dirHandler = dirHandler; @@ -101,7 +103,7 @@ public int statfs(String path, Statvfs stbuf) { @Override public int access(String path, int mask) { try { - Path node = resolvePath(path); + Path node = resolvePath(fileNameTranscoder.fuseToNio(path)); Set accessModes = attrUtil.accessModeMaskToSet(mask); return checkAccess(node, accessModes); } catch (RuntimeException e) { @@ -135,7 +137,7 @@ protected int checkAccess(Path path, Set requiredAccessModes, Set iter = Iterators.concat(sameAndParent, ds.iterator()); while (iter.hasNext()) { String fileName = iter.next().getFileName().toString(); - if (filler.apply(buf, fileName, null, 0) != 0) { + if (filler.apply(buf, fileNameTranscoder.nioToFuse(fileName), null, 0) != 0) { return -ErrorCodes.ENOMEM(); } } diff --git a/src/main/java/org/cryptomator/frontend/fuse/ReadOnlyLinkHandler.java b/src/main/java/org/cryptomator/frontend/fuse/ReadOnlyLinkHandler.java index 4a002f6..190206c 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/ReadOnlyLinkHandler.java +++ b/src/main/java/org/cryptomator/frontend/fuse/ReadOnlyLinkHandler.java @@ -7,7 +7,7 @@ import javax.inject.Inject; import java.io.IOException; -import java.nio.charset.StandardCharsets; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; @@ -19,10 +19,12 @@ class ReadOnlyLinkHandler { private static final Logger LOG = LoggerFactory.getLogger(ReadOnlyLinkHandler.class); private final FileAttributesUtil attrUtil; + private final FileNameTranscoder fileNameTranscoder; @Inject - public ReadOnlyLinkHandler(FileAttributesUtil attrUtil) { + public ReadOnlyLinkHandler(FileAttributesUtil attrUtil, FileNameTranscoder fileNameTranscoder) { this.attrUtil = attrUtil; + this.fileNameTranscoder = fileNameTranscoder; } public int getattr(Path path, BasicFileAttributes attrs, FileStat stat) { @@ -48,10 +50,11 @@ public int getattr(Path path, BasicFileAttributes attrs, FileStat stat) { */ public int readlink(Path path, Pointer buf, long size) throws IOException { Path target = Files.readSymbolicLink(path); - String result = target.toString(); - int maxSize = size == 0 ? 0 : (int) size - 1; - buf.putString(0, result, maxSize, StandardCharsets.UTF_8); - buf.putByte(maxSize, (byte) 0x00); + ByteBuffer fuseEncodedTarget = fileNameTranscoder.interpretAsFuseString(fileNameTranscoder.nioToFuse(target.toString())); + int len = (int) Math.min(fuseEncodedTarget.remaining(), size - 1); + assert len < size; + buf.put(0, fuseEncodedTarget.array(), 0, len); + buf.putByte(len, (byte) 0x00); // add null terminator return 0; } diff --git a/src/main/java/org/cryptomator/frontend/fuse/ReadWriteAdapter.java b/src/main/java/org/cryptomator/frontend/fuse/ReadWriteAdapter.java index 48a21ae..aa25058 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/ReadWriteAdapter.java +++ b/src/main/java/org/cryptomator/frontend/fuse/ReadWriteAdapter.java @@ -51,8 +51,8 @@ public class ReadWriteAdapter extends ReadOnlyAdapter { private final BitMaskEnumUtil bitMaskUtil; @Inject - public ReadWriteAdapter(@Named("root") Path root, @Named("maxFileNameLength") int maxFileNameLength, FileStore fileStore, LockManager lockManager, ReadWriteDirectoryHandler dirHandler, ReadWriteFileHandler fileHandler, ReadOnlyLinkHandler linkHandler, FileAttributesUtil attrUtil, BitMaskEnumUtil bitMaskUtil, OpenFileFactory fileFactory) { - super(root, maxFileNameLength, fileStore, lockManager, dirHandler, fileHandler, linkHandler, attrUtil, fileFactory); + public ReadWriteAdapter(@Named("root") Path root, @Named("maxFileNameLength") int maxFileNameLength, FileNameTranscoder fileNameTranscoder, FileStore fileStore, LockManager lockManager, ReadWriteDirectoryHandler dirHandler, ReadWriteFileHandler fileHandler, ReadOnlyLinkHandler linkHandler, FileAttributesUtil attrUtil, BitMaskEnumUtil bitMaskUtil, OpenFileFactory fileFactory) { + super(root, maxFileNameLength, fileNameTranscoder, fileStore, lockManager, dirHandler, fileHandler, linkHandler, attrUtil, fileFactory); this.fileHandler = fileHandler; this.attrUtil = attrUtil; this.bitMaskUtil = bitMaskUtil; @@ -67,7 +67,7 @@ protected int checkAccess(Path path, Set requiredAccessModes) { public int mkdir(String path, @mode_t long mode) { try (PathLock pathLock = lockManager.createPathLock(path).forWriting(); DataLock dataLock = pathLock.lockDataForWriting()) { - Path node = resolvePath(path); + Path node = resolvePath(fileNameTranscoder.fuseToNio(path)); LOG.trace("mkdir {} ({})", path, mode); Files.createDirectory(node); return 0; @@ -83,20 +83,19 @@ public int mkdir(String path, @mode_t long mode) { } @Override - public int symlink(String oldpath, String newpath) { - try (PathLock pathLock = lockManager.createPathLock(newpath).forWriting(); + public int symlink(String targetPath, String linkPath) { + try (PathLock pathLock = lockManager.createPathLock(linkPath).forWriting(); DataLock dataLock = pathLock.lockDataForWriting()) { - Path link = resolvePath(newpath); - Path target = link.getFileSystem().getPath(oldpath); - ; - LOG.trace("symlink {} -> {}", newpath, oldpath); + Path link = resolvePath(fileNameTranscoder.fuseToNio(linkPath)); + Path target = resolvePath(fileNameTranscoder.fuseToNio(targetPath)); + LOG.trace("symlink {} -> {}", linkPath, targetPath); Files.createSymbolicLink(link, target); return 0; } catch (FileAlreadyExistsException e) { - LOG.warn("symlink {} -> {} failed, file already exists.", newpath, oldpath); + LOG.warn("symlink {} -> {} failed, file already exists.", linkPath, targetPath); return -ErrorCodes.EEXIST(); } catch (FileSystemException e) { - return getErrorCodeForGenericFileSystemException(e, "symlink " + oldpath + " -> " + newpath); + return getErrorCodeForGenericFileSystemException(e, "symlink " + targetPath + " -> " + linkPath); } catch (IOException | RuntimeException e) { LOG.error("symlink failed.", e); return -ErrorCodes.EIO(); @@ -107,7 +106,7 @@ public int symlink(String oldpath, String newpath) { public int create(String path, @mode_t long mode, FuseFileInfo fi) { try (PathLock pathLock = lockManager.createPathLock(path).forWriting(); DataLock dataLock = pathLock.lockDataForWriting()) { - Path node = resolvePath(path); + Path node = resolvePath(fileNameTranscoder.fuseToNio(path)); Set flags = bitMaskUtil.bitMaskToSet(OpenFlags.class, fi.flags.longValue()); LOG.trace("create {} with flags {}", path, flags); if (fileStore.supportsFileAttributeView(PosixFileAttributeView.class)) { @@ -138,7 +137,7 @@ public int chown(String path, @uid_t long uid, @gid_t long gid) { public int chmod(String path, @mode_t long mode) { try (PathLock pathLock = lockManager.createPathLock(path).forReading(); DataLock dataLock = pathLock.lockDataForWriting()) { - Path node = resolvePath(path); + Path node = resolvePath(fileNameTranscoder.fuseToNio(path)); LOG.trace("chmod {} ({})", path, mode); Files.setPosixFilePermissions(node, attrUtil.octalModeToPosixPermissions(mode)); return 0; @@ -158,7 +157,7 @@ public int chmod(String path, @mode_t long mode) { public int unlink(String path) { try (PathLock pathLock = lockManager.createPathLock(path).forWriting(); DataLock dataLock = pathLock.lockDataForWriting()) { - Path node = resolvePath(path); + Path node = resolvePath(fileNameTranscoder.fuseToNio(path)); if (Files.isDirectory(node, LinkOption.NOFOLLOW_LINKS)) { LOG.warn("unlink {} failed, node is a directory.", path); return -ErrorCodes.EISDIR(); @@ -179,7 +178,7 @@ public int unlink(String path) { public int rmdir(String path) { try (PathLock pathLock = lockManager.createPathLock(path).forWriting(); DataLock dataLock = pathLock.lockDataForWriting()) { - Path node = resolvePath(path); + Path node = resolvePath(fileNameTranscoder.fuseToNio(path)); if (!Files.isDirectory(node, LinkOption.NOFOLLOW_LINKS)) { throw new NotDirectoryException(path); } @@ -225,8 +224,8 @@ public int rename(String oldPath, String newPath) { PathLock newPathLock = lockManager.createPathLock(newPath).forWriting(); DataLock newDataLock = newPathLock.lockDataForWriting()) { // TODO: recursively check for open file handles - Path nodeOld = resolvePath(oldPath); - Path nodeNew = resolvePath(newPath); + Path nodeOld = resolvePath(fileNameTranscoder.fuseToNio(oldPath)); + Path nodeNew = resolvePath(fileNameTranscoder.fuseToNio(newPath)); LOG.trace("rename {} to {}", oldPath, newPath); Files.move(nodeOld, nodeNew, StandardCopyOption.REPLACE_EXISTING); return 0; @@ -254,7 +253,7 @@ public int utimens(String path, Timespec[] timespec) { assert timespec.length == 2; try (PathLock pathLock = lockManager.createPathLock(path).forReading(); DataLock dataLock = pathLock.lockDataForWriting()) { - Path node = resolvePath(path); + Path node = resolvePath(fileNameTranscoder.fuseToNio(path)); LOG.trace("utimens {} (last modification {} sec {} nsec, last access {} sec {} nsec)", path, timespec[1].tv_sec.get(), timespec[1].tv_nsec.longValue(), timespec[0].tv_sec.get(), timespec[0].tv_nsec.longValue()); fileHandler.utimens(node, timespec[1], timespec[0]); return 0; @@ -291,7 +290,7 @@ public int write(String path, Pointer buf, @size_t long size, @off_t long offset public int truncate(String path, @off_t long size) { try (PathLock pathLock = lockManager.createPathLock(path).forReading(); DataLock dataLock = pathLock.lockDataForWriting()) { - Path node = resolvePath(path); + Path node = resolvePath(fileNameTranscoder.fuseToNio(path)); LOG.trace("truncate {} {}", path, size); fileHandler.truncate(node, size); return 0; @@ -335,5 +334,5 @@ public int fsync(String path, int isdatasync, FuseFileInfo fi) { return -ErrorCodes.EIO(); } } - + } diff --git a/src/main/java/org/cryptomator/frontend/fuse/ReadWriteDirectoryHandler.java b/src/main/java/org/cryptomator/frontend/fuse/ReadWriteDirectoryHandler.java index fbdb5bf..8be4cc5 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/ReadWriteDirectoryHandler.java +++ b/src/main/java/org/cryptomator/frontend/fuse/ReadWriteDirectoryHandler.java @@ -11,8 +11,8 @@ public class ReadWriteDirectoryHandler extends ReadOnlyDirectoryHandler { @Inject - public ReadWriteDirectoryHandler(FileAttributesUtil attrUtil) { - super(attrUtil); + public ReadWriteDirectoryHandler(FileAttributesUtil attrUtil, FileNameTranscoder fileNameTranscoder) { + super(attrUtil, fileNameTranscoder); } @Override diff --git a/src/main/java/org/cryptomator/frontend/fuse/mount/EnvironmentVariables.java b/src/main/java/org/cryptomator/frontend/fuse/mount/EnvironmentVariables.java index 92cf061..ceaf8ac 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/mount/EnvironmentVariables.java +++ b/src/main/java/org/cryptomator/frontend/fuse/mount/EnvironmentVariables.java @@ -1,18 +1,20 @@ package org.cryptomator.frontend.fuse.mount; +import org.cryptomator.frontend.fuse.FileNameTranscoder; + import java.nio.file.Path; import java.util.Optional; public class EnvironmentVariables { private final Path mountPoint; + private final FileNameTranscoder fileNameTranscoder; private final String[] fuseFlags; - private final Optional revealCommand; - private EnvironmentVariables(Path mountPoint, String[] fuseFlags, Optional revealCommand) { + private EnvironmentVariables(Path mountPoint, String[] fuseFlags, FileNameTranscoder fileNameTranscoder) { this.mountPoint = mountPoint; this.fuseFlags = fuseFlags; - this.revealCommand = revealCommand; + this.fileNameTranscoder = fileNameTranscoder; } public static EnvironmentVariablesBuilder create() { @@ -27,15 +29,15 @@ public String[] getFuseFlags() { return fuseFlags; } - public Optional getRevealCommand() { - return revealCommand; + public FileNameTranscoder getFileNameTranscoder() { + return fileNameTranscoder; } public static class EnvironmentVariablesBuilder { private Path mountPoint = null; private String[] fuseFlags; - private Optional revealCommand = Optional.empty(); + private FileNameTranscoder fileNameTranscoder; public EnvironmentVariablesBuilder withMountPoint(Path mountPoint) { this.mountPoint = mountPoint; @@ -47,13 +49,13 @@ public EnvironmentVariablesBuilder withFlags(String[] fuseFlags) { return this; } - public EnvironmentVariablesBuilder withRevealCommand(String revealCommand) { - this.revealCommand = Optional.ofNullable(revealCommand); + public EnvironmentVariablesBuilder withFileNameTranscoder(FileNameTranscoder fileNameTranscoder) { + this.fileNameTranscoder = fileNameTranscoder; return this; } public EnvironmentVariables build() { - return new EnvironmentVariables(mountPoint, fuseFlags, revealCommand); + return new EnvironmentVariables(mountPoint, fuseFlags, fileNameTranscoder); } } diff --git a/src/main/java/org/cryptomator/frontend/fuse/mount/LinuxMounter.java b/src/main/java/org/cryptomator/frontend/fuse/mount/LinuxMounter.java index ccc430b..669df74 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/mount/LinuxMounter.java +++ b/src/main/java/org/cryptomator/frontend/fuse/mount/LinuxMounter.java @@ -21,7 +21,9 @@ class LinuxMounter implements Mounter { @Override public synchronized Mount mount(Path directory, boolean blocking, boolean debug, EnvironmentVariables envVars) throws CommandFailedException { - FuseNioAdapter fuseAdapter = AdapterFactory.createReadWriteAdapter(directory); + FuseNioAdapter fuseAdapter = AdapterFactory.createReadWriteAdapter(directory, // + AdapterFactory.DEFAULT_MAX_FILENAMELENGTH, // + envVars.getFileNameTranscoder()); try { fuseAdapter.mount(envVars.getMountPoint(), blocking, debug, envVars.getFuseFlags()); } catch (RuntimeException e) { diff --git a/src/main/java/org/cryptomator/frontend/fuse/mount/MacMounter.java b/src/main/java/org/cryptomator/frontend/fuse/mount/MacMounter.java index 564400b..3f57e16 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/mount/MacMounter.java +++ b/src/main/java/org/cryptomator/frontend/fuse/mount/MacMounter.java @@ -1,7 +1,7 @@ package org.cryptomator.frontend.fuse.mount; -import com.google.common.base.Preconditions; import org.cryptomator.frontend.fuse.AdapterFactory; +import org.cryptomator.frontend.fuse.FileNameTranscoder; import org.cryptomator.frontend.fuse.FuseNioAdapter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +26,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.text.Normalizer; import java.util.Arrays; import java.util.concurrent.TimeUnit; @@ -41,7 +42,9 @@ class MacMounter implements Mounter { @Override public synchronized Mount mount(Path directory, boolean blocking, boolean debug, EnvironmentVariables envVars) throws CommandFailedException { - FuseNioAdapter fuseAdapter = AdapterFactory.createReadWriteAdapter(directory); + FuseNioAdapter fuseAdapter = AdapterFactory.createReadWriteAdapter(directory, // + AdapterFactory.DEFAULT_MAX_FILENAMELENGTH, // + envVars.getFileNameTranscoder()); try { fuseAdapter.mount(envVars.getMountPoint(), blocking, debug, envVars.getFuseFlags()); } catch (RuntimeException e) { @@ -60,7 +63,6 @@ public String[] defaultMountFlags() { "-oatomic_o_trunc", "-oauto_xattr", "-oauto_cache", - "-omodules=iconv,from_code=UTF-8,to_code=UTF-8-MAC", // show files names in Unicode NFD encoding "-onoappledouble", // vastly impacts performance for some reason... "-odefault_permissions" // let the kernel assume permissions based on file attributes etc }; @@ -69,6 +71,11 @@ public String[] defaultMountFlags() { } } + @Override + public FileNameTranscoder defaultFileNameTranscoder() { + return FileNameTranscoder.transcoder().withFuseNormalization(Normalizer.Form.NFD); + } + /** * @return true if on OS X and osxfuse with a higher version than the minimum supported one is installed. */ diff --git a/src/main/java/org/cryptomator/frontend/fuse/mount/Mounter.java b/src/main/java/org/cryptomator/frontend/fuse/mount/Mounter.java index 2502388..c4e4aa7 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/mount/Mounter.java +++ b/src/main/java/org/cryptomator/frontend/fuse/mount/Mounter.java @@ -1,5 +1,7 @@ package org.cryptomator.frontend.fuse.mount; +import org.cryptomator.frontend.fuse.FileNameTranscoder; + import java.nio.file.Path; public interface Mounter { @@ -14,4 +16,8 @@ default Mount mount(Path directory, EnvironmentVariables envVars) throws Command boolean isApplicable(); + default FileNameTranscoder defaultFileNameTranscoder() { + return FileNameTranscoder.transcoder(); + } + } diff --git a/src/main/java/org/cryptomator/frontend/fuse/mount/WindowsMounter.java b/src/main/java/org/cryptomator/frontend/fuse/mount/WindowsMounter.java index d562574..22a0d87 100644 --- a/src/main/java/org/cryptomator/frontend/fuse/mount/WindowsMounter.java +++ b/src/main/java/org/cryptomator/frontend/fuse/mount/WindowsMounter.java @@ -17,7 +17,9 @@ class WindowsMounter implements Mounter { @Override public synchronized Mount mount(Path directory, boolean blocking, boolean debug, EnvironmentVariables envVars) throws CommandFailedException { - FuseNioAdapter fuseAdapter = AdapterFactory.createReadWriteAdapter(directory); + FuseNioAdapter fuseAdapter = AdapterFactory.createReadWriteAdapter(directory, // + AdapterFactory.DEFAULT_MAX_FILENAMELENGTH, // + envVars.getFileNameTranscoder()); try { fuseAdapter.mount(envVars.getMountPoint(), blocking, debug, envVars.getFuseFlags()); } catch (RuntimeException e) { @@ -41,7 +43,7 @@ private static boolean isWinFspInstalled() { String path = WinPathUtils.getWinFspPath(); //Result only matters for debug-message; null-check is included in lib LOG.trace("Found WinFsp installation at {}", path); return true; - } catch(FuseException exc) { + } catch (FuseException exc) { LOG.debug("Failed to find a WinFsp installation; that's only a problem if you want to use FUSE on Windows. Exception text: \"{}\"", exc.getMessage()); return false; } diff --git a/src/test/java/org/cryptomator/frontend/fuse/FileNameTranscoderTest.java b/src/test/java/org/cryptomator/frontend/fuse/FileNameTranscoderTest.java new file mode 100644 index 0000000..f6febdd --- /dev/null +++ b/src/test/java/org/cryptomator/frontend/fuse/FileNameTranscoderTest.java @@ -0,0 +1,140 @@ +package org.cryptomator.frontend.fuse; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; + +import static java.nio.charset.StandardCharsets.ISO_8859_1; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.text.Normalizer.Form.NFC; +import static java.text.Normalizer.Form.NFD; + +public class FileNameTranscoderTest { + + private static final Charset COMPARSION_CS = StandardCharsets.UTF_16; + private MockedStatic normalizerClass; + + @BeforeEach + public void setup() { + normalizerClass = Mockito.mockStatic(Normalizer.class); + normalizerClass.when(() -> Normalizer.normalize(Mockito.any(), Mockito.any())).thenCallRealMethod(); + } + + @AfterEach + public void tearDown() { + normalizerClass.close(); + } + + @Nested + @DisplayName("UTF-8/NFC <-> UTF-8/NFC") + public class NoopTranscoder { + + private FileNameTranscoder transcoder; + + @BeforeEach + public void setup() { + this.transcoder = new FileNameTranscoder(UTF_8, UTF_8, NFC, NFC); + } + + @ParameterizedTest + @DisplayName("fuseToNio()") + @ValueSource(strings = {"", "This is a test", "äöü", "🙂🐱"}) + public void testNoopTranscodeFuseToNio(String str) { + String result = transcoder.fuseToNio(str); + + normalizerClass.verifyNoInteractions(); + Assertions.assertArrayEquals(str.getBytes(COMPARSION_CS), result.getBytes(COMPARSION_CS)); + } + + @ParameterizedTest + @DisplayName("nioToFuse()") + @ValueSource(strings = {"", "This is a test", "äöü", "🙂🐱"}) + public void testNoopTranscodeNioToFuse(String str) { + String result = transcoder.nioToFuse(str); + + normalizerClass.verifyNoInteractions(); + Assertions.assertArrayEquals(str.getBytes(COMPARSION_CS), result.getBytes(COMPARSION_CS)); + } + + } + + @Nested + @DisplayName("UTF-8/NFC <-> UTF-8/NFD") + public class TransNormalizer { + + private FileNameTranscoder transcoder; + + @BeforeEach + public void setup() { + this.transcoder = new FileNameTranscoder(UTF_8, UTF_8, NFD, NFC); + } + + @ParameterizedTest + @DisplayName("fuseToNio()") + @ValueSource(strings = {"", "This is a test", "äöü", "🙂🐱"}) + public void testNoopTranscodeFuseToNio(String str) { + String result = transcoder.fuseToNio(str); + + normalizerClass.verify(() -> Normalizer.normalize(str, NFC)); + Assertions.assertEquals(Normalizer.normalize(str, NFC), Normalizer.normalize(result, NFC)); + } + + @ParameterizedTest + @DisplayName("nioToFuse()") + @ValueSource(strings = {"", "This is a test", "äöü", "🙂🐱"}) + public void testNoopTranscodeNioToFuse(String str) { + String result = transcoder.nioToFuse(str); + + normalizerClass.verify(() -> Normalizer.normalize(str, NFD)); + Assertions.assertEquals(Normalizer.normalize(str, NFC), Normalizer.normalize(result, NFC)); + } + + } + + @Nested + @DisplayName("UTF-8 <-> ISO-8859-1") + public class ReEncoder { + + private FileNameTranscoder transcoder; + + @BeforeEach + public void setup() { + this.transcoder = new FileNameTranscoder(ISO_8859_1, UTF_8, NFC, NFC); + } + + @ParameterizedTest + @DisplayName("toFuse(str) != str") + @ValueSource(strings = {"äöü", "🙂🐱"}) + public void testTranscodeToLatin(String str) { + Assumptions.assumeTrue(str.chars().anyMatch(c -> c > 0x7F), "str does not contain multi-byte unicode character"); + + String fuseStr = transcoder.nioToFuse(str); + + normalizerClass.verifyNoInteractions(); + Assertions.assertNotEquals(str, fuseStr); + } + + @ParameterizedTest + @DisplayName("toNio(toFuse(str)) == str") + @ValueSource(strings = {"", "This is a test", "äöü", "🙂🐱"}) + public void testLosslessBackAndForth(String str) { + String result = transcoder.fuseToNio(transcoder.nioToFuse(str)); + + normalizerClass.verifyNoInteractions(); + Assertions.assertEquals(str, result); + } + + } + +} diff --git a/src/test/java/org/cryptomator/frontend/fuse/mount/EnvironmentVariablesTest.java b/src/test/java/org/cryptomator/frontend/fuse/mount/EnvironmentVariablesTest.java index 6a8a102..c4e61c3 100644 --- a/src/test/java/org/cryptomator/frontend/fuse/mount/EnvironmentVariablesTest.java +++ b/src/test/java/org/cryptomator/frontend/fuse/mount/EnvironmentVariablesTest.java @@ -1,5 +1,6 @@ package org.cryptomator.frontend.fuse.mount; +import org.cryptomator.frontend.fuse.FileNameTranscoder; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -11,11 +12,13 @@ public class EnvironmentVariablesTest { @Test public void testEnvironmentVariablesBuilder() { String[] flags = new String[]{"--test", "--debug"}; + FileNameTranscoder transcoder = FileNameTranscoder.transcoder(); Path mountPoint = Paths.get("/home/testuser/mnt"); - EnvironmentVariables envVars = EnvironmentVariables.create().withFlags(flags).withMountPoint(mountPoint).build(); + EnvironmentVariables envVars = EnvironmentVariables.create().withFlags(flags).withFileNameTranscoder(transcoder).withMountPoint(mountPoint).build(); Assertions.assertEquals(flags, envVars.getFuseFlags()); + Assertions.assertEquals(transcoder, envVars.getFileNameTranscoder()); Assertions.assertEquals(mountPoint, envVars.getMountPoint()); } } diff --git a/src/test/java/org/cryptomator/frontend/fuse/mount/LinuxEnvironmentTest.java b/src/test/java/org/cryptomator/frontend/fuse/mount/LinuxEnvironmentTest.java index d483fbd..4ce24d4 100644 --- a/src/test/java/org/cryptomator/frontend/fuse/mount/LinuxEnvironmentTest.java +++ b/src/test/java/org/cryptomator/frontend/fuse/mount/LinuxEnvironmentTest.java @@ -16,7 +16,6 @@ public static void main(String[] args) throws IOException { EnvironmentVariables envVars = EnvironmentVariables.create() .withFlags(mounter.defaultMountFlags()) .withMountPoint(mountPoint) - .withRevealCommand("nautilus") .build(); Path tmp = Paths.get("/tmp"); try (Mount mnt = mounter.mount(tmp, envVars)) { diff --git a/src/test/java/org/cryptomator/frontend/fuse/mount/MirroringFuseMountTest.java b/src/test/java/org/cryptomator/frontend/fuse/mount/MirroringFuseMountTest.java index e7ca656..544d06e 100644 --- a/src/test/java/org/cryptomator/frontend/fuse/mount/MirroringFuseMountTest.java +++ b/src/test/java/org/cryptomator/frontend/fuse/mount/MirroringFuseMountTest.java @@ -181,6 +181,7 @@ private static void mount(Path pathToMirror, Path mountPoint) { EnvironmentVariables envVars = EnvironmentVariables.create() .withFlags(mounter.defaultMountFlags()) .withMountPoint(mountPoint) + .withFileNameTranscoder(mounter.defaultFileNameTranscoder()) .build(); try (Mount mnt = mounter.mount(pathToMirror, envVars)) { LOG.info("Mounted successfully. Enter anything to stop the server..."); diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file