Skip to content

Commit 024c15d

Browse files
authored
Merge pull request #118 from cryptomator/feature/113-dirId-stored-in-dir
Store DirId for every cleartext dir encrypted inside the ciphertext directory
2 parents b839579 + 310c038 commit 024c15d

File tree

9 files changed

+218
-19
lines changed

9 files changed

+218
-19
lines changed

src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868

6969
@CryptoFileSystemScoped
7070
class CryptoFileSystemImpl extends CryptoFileSystem {
71-
71+
7272
private final CryptoFileSystemProvider provider;
7373
private final CryptoFileSystems cryptoFileSystems;
7474
private final Path pathToVault;
@@ -80,6 +80,7 @@ class CryptoFileSystemImpl extends CryptoFileSystem {
8080
private final PathMatcherFactory pathMatcherFactory;
8181
private final DirectoryStreamFactory directoryStreamFactory;
8282
private final DirectoryIdProvider dirIdProvider;
83+
private final DirectoryIdBackup dirIdBackup;
8384
private final AttributeProvider fileAttributeProvider;
8485
private final AttributeByNameProvider fileAttributeByNameProvider;
8586
private final AttributeViewProvider fileAttributeViewProvider;
@@ -98,7 +99,7 @@ class CryptoFileSystemImpl extends CryptoFileSystem {
9899
@Inject
99100
public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems cryptoFileSystems, @PathToVault Path pathToVault, Cryptor cryptor,
100101
CryptoFileStore fileStore, CryptoFileSystemStats stats, CryptoPathMapper cryptoPathMapper, CryptoPathFactory cryptoPathFactory,
101-
PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider,
102+
PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider, DirectoryIdBackup dirIdBackup,
102103
AttributeProvider fileAttributeProvider, AttributeByNameProvider fileAttributeByNameProvider, AttributeViewProvider fileAttributeViewProvider,
103104
OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag,
104105
CryptoFileSystemProperties fileSystemProperties) {
@@ -113,6 +114,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems
113114
this.pathMatcherFactory = pathMatcherFactory;
114115
this.directoryStreamFactory = directoryStreamFactory;
115116
this.dirIdProvider = dirIdProvider;
117+
this.dirIdBackup = dirIdBackup;
116118
this.fileAttributeProvider = fileAttributeProvider;
117119
this.fileAttributeByNameProvider = fileAttributeByNameProvider;
118120
this.fileAttributeViewProvider = fileAttributeViewProvider;
@@ -235,8 +237,8 @@ <A extends BasicFileAttributes> A readAttributes(CryptoPath cleartextPath, Class
235237

236238
/**
237239
* @param cleartextPath the path to the file
238-
* @param type the Class object corresponding to the file attribute view
239-
* @param options future use
240+
* @param type the Class object corresponding to the file attribute view
241+
* @param options future use
240242
* @return a file attribute view of the specified type, or <code>null</code> if the attribute view type is not available
241243
* @see AttributeViewProvider#getAttributeView(CryptoPath, Class, LinkOption...)
242244
*/
@@ -302,6 +304,7 @@ void createDirectory(CryptoPath cleartextDir, FileAttribute<?>... attrs) throws
302304
// create dir if and only if the dirFile has been created right now (not if it has been created before):
303305
try {
304306
Files.createDirectories(ciphertextDir.path);
307+
dirIdBackup.execute(ciphertextDir);
305308
ciphertextPath.persistLongFileName();
306309
} catch (IOException e) {
307310
// make sure there is no orphan dir file:
@@ -582,7 +585,7 @@ private void moveDirectory(CryptoPath cleartextSource, CryptoPath cleartextTarge
582585
}
583586
Files.walkFileTree(ciphertextTarget.getRawPath(), DeletingFileVisitor.INSTANCE);
584587
}
585-
588+
586589
// no exceptions until this point, so MOVE:
587590
Files.move(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath(), options);
588591
if (ciphertextTarget.isShortened()) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package org.cryptomator.cryptofs;
2+
3+
import org.cryptomator.cryptofs.common.Constants;
4+
import org.cryptomator.cryptolib.api.Cryptor;
5+
import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel;
6+
7+
import javax.inject.Inject;
8+
import java.io.IOException;
9+
import java.nio.ByteBuffer;
10+
import java.nio.channels.ByteChannel;
11+
import java.nio.charset.StandardCharsets;
12+
import java.nio.file.Files;
13+
import java.nio.file.StandardOpenOption;
14+
15+
/**
16+
* Single purpose class to backup the directory id of an encrypted directory when it is created.
17+
*/
18+
@CryptoFileSystemScoped
19+
public class DirectoryIdBackup {
20+
21+
private Cryptor cryptor;
22+
23+
@Inject
24+
public DirectoryIdBackup(Cryptor cryptor) {
25+
this.cryptor = cryptor;
26+
}
27+
28+
/**
29+
* Performs the backup operation for the given {@link CryptoPathMapper.CiphertextDirectory} object.
30+
* <p>
31+
* The directory id is written via an encrypting channel to the file {@link CryptoPathMapper.CiphertextDirectory#path}/{@value Constants#DIR_ID_FILE}.
32+
*
33+
* @param ciphertextDirectory The cipher dir object containing the dir id and the encrypted content root
34+
* @throws IOException if an IOException is raised during the write operation
35+
*/
36+
public void execute(CryptoPathMapper.CiphertextDirectory ciphertextDirectory) throws IOException {
37+
try (var channel = Files.newByteChannel(ciphertextDirectory.path.resolve(Constants.DIR_ID_FILE), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); //
38+
var encryptingChannel = wrapEncryptionAround(channel, cryptor)) {
39+
encryptingChannel.write(ByteBuffer.wrap(ciphertextDirectory.dirId.getBytes(StandardCharsets.UTF_8)));
40+
}
41+
}
42+
43+
static EncryptingWritableByteChannel wrapEncryptionAround(ByteChannel channel, Cryptor cryptor) {
44+
return new EncryptingWritableByteChannel(channel, cryptor);
45+
}
46+
}

src/main/java/org/cryptomator/cryptofs/common/Constants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ private Constants() {
2929
public static final int DEFAULT_SHORTENING_THRESHOLD = 220;
3030
public static final int MAX_SYMLINK_LENGTH = 32767; // max path length on NTFS and FAT32: 32k-1
3131
public static final int MAX_DIR_FILE_LENGTH = 36; // UUIDv4: hex-encoded 16 byte int + 4 hyphens = 36 ASCII chars
32+
public static final int MIN_CIPHER_NAME_LENGTH = 26; //rounded up base64url encoded (16 bytes IV + 0 bytes empty string) + file suffix = 26 ASCII chars
3233

3334
public static final String SEPARATOR = "/";
3435
public static final String RECOVERY_DIR_NAME = "CRYPTOMATOR_RECOVERY";
36+
public static final String DIR_ID_FILE = "dirid.c9r";
3537
}

src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.cryptomator.cryptofs.CryptoPath;
55
import org.cryptomator.cryptofs.CryptoPathMapper;
66
import org.cryptomator.cryptofs.CryptoPathMapper.CiphertextDirectory;
7+
import org.cryptomator.cryptofs.common.Constants;
78

89
import javax.inject.Inject;
910
import java.io.IOException;
@@ -13,15 +14,14 @@
1314
import java.nio.file.Files;
1415
import java.nio.file.Path;
1516
import java.util.HashMap;
16-
import java.util.Iterator;
1717
import java.util.Map;
1818

1919
@CryptoFileSystemScoped
2020
public class DirectoryStreamFactory {
2121

2222
private final CryptoPathMapper cryptoPathMapper;
2323
private final DirectoryStreamComponent.Builder directoryStreamComponentBuilder; // sharing reusable builder via synchronized
24-
private final Map<CryptoDirectoryStream, DirectoryStream> streams = new HashMap<>();
24+
private final Map<CryptoDirectoryStream, DirectoryStream<Path>> streams = new HashMap<>();
2525

2626
private volatile boolean closed = false;
2727

@@ -36,7 +36,8 @@ public synchronized CryptoDirectoryStream newDirectoryStream(CryptoPath cleartex
3636
throw new ClosedFileSystemException();
3737
}
3838
CiphertextDirectory ciphertextDir = cryptoPathMapper.getCiphertextDir(cleartextDir);
39-
DirectoryStream<Path> ciphertextDirStream = Files.newDirectoryStream(ciphertextDir.path);
39+
//TODO: use HealthCheck with warning and suggest fix to create one
40+
DirectoryStream<Path> ciphertextDirStream = Files.newDirectoryStream(ciphertextDir.path, this::matchesEncryptedContentPattern);
4041
CryptoDirectoryStream cleartextDirStream = directoryStreamComponentBuilder //
4142
.dirId(ciphertextDir.dirId) //
4243
.ciphertextDirectoryStream(ciphertextDirStream) //
@@ -49,12 +50,19 @@ public synchronized CryptoDirectoryStream newDirectoryStream(CryptoPath cleartex
4950
return cleartextDirStream;
5051
}
5152

53+
//visible for testing
54+
boolean matchesEncryptedContentPattern(Path path) {
55+
var tmp = path.getFileName().toString();
56+
return tmp.length() >= Constants.MIN_CIPHER_NAME_LENGTH //
57+
&& (tmp.endsWith(Constants.CRYPTOMATOR_FILE_SUFFIX) || tmp.endsWith(Constants.DEFLATED_FILE_SUFFIX));
58+
}
59+
5260
public synchronized void close() throws IOException {
5361
closed = true;
5462
IOException exception = new IOException("Close failed");
55-
Iterator<Map.Entry<CryptoDirectoryStream, DirectoryStream>> iter = streams.entrySet().iterator();
63+
var iter = streams.entrySet().iterator();
5664
while (iter.hasNext()) {
57-
Map.Entry<CryptoDirectoryStream, DirectoryStream> entry = iter.next();
65+
var entry = iter.next();
5866
iter.remove();
5967
try {
6068
entry.getKey().close();

src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public class CryptoFileSystemImplTest {
8888
private final Symlinks symlinks = mock(Symlinks.class);
8989
private final CryptoPathMapper cryptoPathMapper = mock(CryptoPathMapper.class);
9090
private final DirectoryIdProvider dirIdProvider = mock(DirectoryIdProvider.class);
91+
private final DirectoryIdBackup dirIdBackup = mock(DirectoryIdBackup.class);
9192
private final AttributeProvider fileAttributeProvider = mock(AttributeProvider.class);
9293
private final AttributeByNameProvider fileAttributeByNameProvider = mock(AttributeByNameProvider.class);
9394
private final AttributeViewProvider fileAttributeViewProvider = mock(AttributeViewProvider.class);
@@ -118,7 +119,7 @@ public void setup() {
118119

119120
inTest = new CryptoFileSystemImpl(provider, cryptoFileSystems, pathToVault, cryptor,
120121
fileStore, stats, cryptoPathMapper, cryptoPathFactory,
121-
pathMatcherFactory, directoryStreamFactory, dirIdProvider,
122+
pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup,
122123
fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider,
123124
openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag,
124125
fileSystemProperties);
@@ -1103,6 +1104,37 @@ public void createDirectoryClearsDirIdAndDeletesDirFileIfCreatingDirFails() thro
11031104
verify(cryptoPathMapper).invalidatePathMapping(path);
11041105
}
11051106

1107+
@Test
1108+
public void createDirectoryBackupsDirIdInCiphertextDirPath() throws IOException {
1109+
Path ciphertextParent = mock(Path.class, "ciphertextParent");
1110+
Path ciphertextRawPath = mock(Path.class, "d/00/00/path.c9r");
1111+
Path ciphertextDirFile = mock(Path.class, "d/00/00/path.c9r/dir.c9r");
1112+
Path ciphertextDirPath = mock(Path.class, "d/FF/FF/");
1113+
CiphertextFilePath ciphertextPath = mock(CiphertextFilePath.class, "ciphertext");
1114+
String dirId = "DirId1234ABC";
1115+
CiphertextDirectory cipherDirObject = new CiphertextDirectory(dirId, ciphertextDirPath);
1116+
FileChannelMock channel = new FileChannelMock(100);
1117+
when(ciphertextRawPath.resolve("dir.c9r")).thenReturn(ciphertextDirFile);
1118+
when(cryptoPathMapper.getCiphertextFilePath(path)).thenReturn(ciphertextPath);
1119+
when(cryptoPathMapper.getCiphertextDir(path)).thenReturn(cipherDirObject);
1120+
when(cryptoPathMapper.getCiphertextDir(parent)).thenReturn(new CiphertextDirectory("parentDirId", ciphertextDirPath));
1121+
when(cryptoPathMapper.getCiphertextFileType(path)).thenThrow(NoSuchFileException.class);
1122+
when(ciphertextPath.getRawPath()).thenReturn(ciphertextRawPath);
1123+
when(ciphertextPath.getDirFilePath()).thenReturn(ciphertextDirFile);
1124+
when(ciphertextParent.getFileSystem()).thenReturn(fileSystem);
1125+
when(ciphertextRawPath.getFileSystem()).thenReturn(fileSystem);
1126+
when(ciphertextDirFile.getFileSystem()).thenReturn(fileSystem);
1127+
when(ciphertextDirPath.getFileSystem()).thenReturn(fileSystem);
1128+
when(ciphertextDirFile.getName(3)).thenReturn(mock(Path.class, "path.c9r"));
1129+
when(provider.newFileChannel(ciphertextDirFile, EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))).thenReturn(channel);
1130+
1131+
inTest.createDirectory(path);
1132+
1133+
verify(readonlyFlag).assertWritable();
1134+
verify(dirIdBackup, Mockito.times(1)).execute(cipherDirObject);
1135+
}
1136+
1137+
11061138
}
11071139

11081140
@Nested

src/test/java/org/cryptomator/cryptofs/DeleteNonEmptyCiphertextDirectoryIntegrationTest.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.junit.jupiter.api.Assertions;
1717
import org.junit.jupiter.api.BeforeAll;
1818
import org.junit.jupiter.api.Disabled;
19+
import org.junit.jupiter.api.DisplayName;
1920
import org.junit.jupiter.api.Test;
2021
import org.junit.jupiter.api.io.TempDir;
2122
import org.mockito.Mockito;
@@ -28,6 +29,7 @@
2829
import java.nio.file.FileSystem;
2930
import java.nio.file.Files;
3031
import java.nio.file.Path;
32+
import java.util.function.Predicate;
3133
import java.util.stream.Stream;
3234

3335
import static java.nio.file.StandardOpenOption.CREATE_NEW;
@@ -159,21 +161,35 @@ private Path firstEmptyCiphertextDirectory() throws IOException {
159161
try (Stream<Path> allFilesInVaultDir = Files.walk(pathToVault)) {
160162
return allFilesInVaultDir //
161163
.filter(Files::isDirectory) //
162-
.filter(this::isEmptyDirectory) //
164+
.filter(this::isEmptyCryptoFsDirectory) //
163165
.filter(this::isEncryptedDirectory) //
164166
.findFirst() //
165167
.get();
166168
}
167169
}
168170

169-
private boolean isEmptyDirectory(Path path) {
171+
private boolean isEmptyCryptoFsDirectory(Path path) {
172+
Predicate<Path> isIgnoredFile = p -> Constants.DIR_ID_FILE.equals(p.getFileName().toString());
170173
try (Stream<Path> files = Files.list(path)) {
171-
return files.count() == 0;
174+
return files.noneMatch(isIgnoredFile.negate());
172175
} catch (IOException e) {
173176
throw new UncheckedIOException(e);
174177
}
175178
}
176179

180+
@Test
181+
@DisplayName("Tests internal cryptofs directory emptiness definition")
182+
public void testCryptoFsDirEmptiness() throws IOException {
183+
var emptiness = pathToVault.getParent().resolve("emptiness");
184+
var ignoredFile = emptiness.resolve(Constants.DIR_ID_FILE);
185+
Files.createDirectory(emptiness);
186+
Files.createFile(ignoredFile);
187+
188+
boolean result = isEmptyCryptoFsDirectory(emptiness);
189+
190+
Assertions.assertTrue(result, "Ciphertext directory containing only dirId-file should be accepted as an empty dir");
191+
}
192+
177193
private boolean isEncryptedDirectory(Path pathInVault) {
178194
Path relativePath = pathToVault.relativize(pathInVault);
179195
String relativePathAsString = relativePath.toString().replace(File.separatorChar, '/');
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package org.cryptomator.cryptofs;
2+
3+
import org.cryptomator.cryptofs.common.Constants;
4+
import org.cryptomator.cryptolib.api.Cryptor;
5+
import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel;
6+
import org.junit.jupiter.api.Assertions;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
import org.junit.jupiter.api.io.TempDir;
10+
import org.mockito.MockedStatic;
11+
import org.mockito.Mockito;
12+
13+
import java.io.IOException;
14+
import java.nio.ByteBuffer;
15+
import java.nio.charset.StandardCharsets;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
19+
public class DirectoryIdBackupTest {
20+
21+
@TempDir
22+
Path contentPath;
23+
24+
private String dirId = "12345678";
25+
private CryptoPathMapper.CiphertextDirectory cipherDirObject;
26+
private EncryptingWritableByteChannel encChannel;
27+
private Cryptor cryptor;
28+
29+
private DirectoryIdBackup dirIdBackup;
30+
31+
32+
@BeforeEach
33+
public void init() {
34+
cipherDirObject = new CryptoPathMapper.CiphertextDirectory(dirId, contentPath);
35+
cryptor = Mockito.mock(Cryptor.class);
36+
encChannel = Mockito.mock(EncryptingWritableByteChannel.class);
37+
38+
dirIdBackup = new DirectoryIdBackup(cryptor);
39+
}
40+
41+
@Test
42+
public void testIdFileCreated() throws IOException {
43+
try (MockedStatic<DirectoryIdBackup> backupMock = Mockito.mockStatic(DirectoryIdBackup.class)) {
44+
backupMock.when(() -> DirectoryIdBackup.wrapEncryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(encChannel);
45+
Mockito.when(encChannel.write(Mockito.any())).thenReturn(0);
46+
47+
dirIdBackup.execute(cipherDirObject);
48+
49+
Assertions.assertTrue(Files.exists(contentPath.resolve(Constants.DIR_ID_FILE)));
50+
}
51+
}
52+
53+
@Test
54+
public void testContentIsWritten() throws IOException {
55+
Mockito.when(encChannel.write(Mockito.any())).thenReturn(0);
56+
var expectedWrittenContent = ByteBuffer.wrap(dirId.getBytes(StandardCharsets.UTF_8));
57+
58+
try (MockedStatic<DirectoryIdBackup> backupMock = Mockito.mockStatic(DirectoryIdBackup.class)) {
59+
backupMock.when(() -> DirectoryIdBackup.wrapEncryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(encChannel);
60+
61+
dirIdBackup.execute(cipherDirObject);
62+
63+
Mockito.verify(encChannel, Mockito.times(1)).write(Mockito.argThat(b -> b.equals(expectedWrittenContent)));
64+
}
65+
}
66+
67+
}

src/test/java/org/cryptomator/cryptofs/dir/CryptoDirectoryStreamTest.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import org.junit.jupiter.api.Assertions;
1212
import org.junit.jupiter.api.BeforeEach;
1313
import org.junit.jupiter.api.Test;
14-
import org.mockito.ArgumentMatcher;
1514
import org.mockito.Mockito;
1615

1716
import java.io.IOException;
@@ -32,7 +31,7 @@ public class CryptoDirectoryStreamTest {
3231
private static final Consumer<CryptoDirectoryStream> DO_NOTHING_ON_CLOSE = ignored -> {
3332
};
3433
private static final Filter<? super Path> ACCEPT_ALL = ignored -> true;
35-
34+
3635
private NodeProcessor nodeProcessor;
3736
private DirectoryStream<Path> dirStream;
3837

@@ -70,7 +69,7 @@ public void testDirListing() throws IOException {
7069
Mockito.doAnswer(invocation -> {
7170
return Stream.empty();
7271
}).when(nodeProcessor).process(Mockito.argThat(node -> node.fullCiphertextFileName.equals("invalidCiphertext")));
73-
72+
7473
try (CryptoDirectoryStream stream = new CryptoDirectoryStream("foo", dirStream, cleartextPath, ACCEPT_ALL, DO_NOTHING_ON_CLOSE, nodeProcessor)) {
7574
Iterator<Path> iter = stream.iterator();
7675
Assertions.assertTrue(iter.hasNext());

0 commit comments

Comments
 (0)