Skip to content

Commit

Permalink
refactor dir cache to own inner static class
Browse files Browse the repository at this point in the history
  • Loading branch information
infeo committed Oct 12, 2024
1 parent 84e7167 commit 3515794
Show file tree
Hide file tree
Showing 2 changed files with 62 additions and 135 deletions.
88 changes: 62 additions & 26 deletions src/main/java/org/cryptomator/cryptofs/CryptoPathMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,14 @@ public class CryptoPathMapper {

private static final Logger LOG = LoggerFactory.getLogger(CryptoPathMapper.class);
private static final int MAX_CACHED_CIPHERTEXT_NAMES = 5000;
private static final int MAX_CACHED_DIR_PATHS = 5000;
private static final Duration MAX_CACHE_AGE = Duration.ofSeconds(20);

private final Cryptor cryptor;
private final Path dataRoot;
private final DirectoryIdProvider dirIdProvider;
private final LongFileNameProvider longFileNameProvider;
private final VaultConfig vaultConfig;
private final LoadingCache<DirIdAndName, String> ciphertextNames;
private final AsyncCache<CryptoPath, CiphertextDirectory> ciphertextDirectories;
private final ClearToCipherDirCache clearToCipherDirCache;

private final CiphertextDirectory rootDirectory;

Expand All @@ -62,7 +60,7 @@ public class CryptoPathMapper {
this.longFileNameProvider = longFileNameProvider;
this.vaultConfig = vaultConfig;
this.ciphertextNames = Caffeine.newBuilder().maximumSize(MAX_CACHED_CIPHERTEXT_NAMES).build(this::getCiphertextFileName);
this.ciphertextDirectories = Caffeine.newBuilder().maximumSize(MAX_CACHED_DIR_PATHS).expireAfterWrite(MAX_CACHE_AGE).buildAsync();
this.clearToCipherDirCache = new ClearToCipherDirCache();
this.rootDirectory = resolveDirectory(Constants.ROOT_DIR_ID);
}

Expand Down Expand Up @@ -141,39 +139,31 @@ private String getCiphertextFileName(DirIdAndName dirIdAndName) {
return cryptor.fileNameCryptor().encryptFilename(BaseEncoding.base64Url(), dirIdAndName.name, dirIdAndName.dirId.getBytes(StandardCharsets.UTF_8)) + Constants.CRYPTOMATOR_FILE_SUFFIX;
}

/**
* TODO: doc doc doc
* @param cleartextPath
*/
public void invalidatePathMapping(CryptoPath cleartextPath) {
ciphertextDirectories.asMap().keySet().removeIf(p -> p.startsWith(cleartextPath));
clearToCipherDirCache.removeAllKeysWithPrefix(cleartextPath);
}

/**
* TODO: doc doc doc
*/
public void movePathMapping(CryptoPath cleartextSrc, CryptoPath cleartextDst) {
var remappedEntries = new ArrayList<Map.Entry<CryptoPath, CompletableFuture<CiphertextDirectory>>>();
ciphertextDirectories.asMap().entrySet().removeIf(e -> {
if (e.getKey().startsWith(cleartextSrc)) {
var remappedPath = cleartextDst.resolve(cleartextSrc.relativize(e.getKey()));
return remappedEntries.add(Map.entry(remappedPath, e.getValue()));
} else {
return false;
}
});
remappedEntries.forEach(e -> ciphertextDirectories.put(e.getKey(), e.getValue()));
clearToCipherDirCache.recomputeAllKeysWithPrefix(cleartextSrc, cleartextDst);
}

public CiphertextDirectory getCiphertextDir(CryptoPath cleartextPath) throws IOException {
assert cleartextPath.isAbsolute();
CryptoPath parentPath = cleartextPath.getParent();
if (parentPath == null) {
if (cleartextPath.getParent() == null) {
return rootDirectory;
} else {
var lazyEntry = new CompletableFuture<CiphertextDirectory>();
var priorEntry = ciphertextDirectories.asMap().putIfAbsent(cleartextPath, lazyEntry);
if (priorEntry != null) {
return priorEntry.join();
} else {
CipherDirLoader cipherDirLoaderIfAbsent = () -> {
Path dirFile = getCiphertextFilePath(cleartextPath).getDirFilePath();
CiphertextDirectory cipherDir = resolveDirectory(dirFile);
lazyEntry.complete(cipherDir);
return cipherDir;
}
return resolveDirectory(dirFile);
};
return clearToCipherDirCache.putIfAbsent(cleartextPath, cipherDirLoaderIfAbsent);
}
}

Expand Down Expand Up @@ -240,4 +230,50 @@ public boolean equals(Object obj) {
}
}

static class ClearToCipherDirCache {

private static final int MAX_CACHED_DIR_PATHS = 5000;
private static final Duration MAX_CACHE_AGE = Duration.ofSeconds(20);

private final AsyncCache<CryptoPath, CiphertextDirectory> ciphertextDirectories = Caffeine.newBuilder() //
.maximumSize(MAX_CACHED_DIR_PATHS) //
.expireAfterWrite(MAX_CACHE_AGE) //
.buildAsync();

void removeAllKeysWithPrefix(CryptoPath basePrefix) {
ciphertextDirectories.asMap().keySet().removeIf(p -> p.startsWith(basePrefix));
}

void recomputeAllKeysWithPrefix(CryptoPath oldPrefix, CryptoPath newPrefix) {
var remappedEntries = new ArrayList<Map.Entry<CryptoPath, CompletableFuture<CiphertextDirectory>>>();
ciphertextDirectories.asMap().entrySet().removeIf(e -> {
if (e.getKey().startsWith(oldPrefix)) {
var remappedPath = newPrefix.resolve(oldPrefix.relativize(e.getKey()));
return remappedEntries.add(Map.entry(remappedPath, e.getValue()));
} else {
return false;
}
});
remappedEntries.forEach(e -> ciphertextDirectories.put(e.getKey(), e.getValue()));
}

CiphertextDirectory putIfAbsent(CryptoPath cleartextPath, CipherDirLoader ifAbsent) throws IOException {
var futureMapping = new CompletableFuture<CiphertextDirectory>();
var currentMapping = ciphertextDirectories.asMap().putIfAbsent(cleartextPath, futureMapping);
if (currentMapping != null) {
return currentMapping.join();
} else {
futureMapping.complete(ifAbsent.load());
return futureMapping.join();
}
}

}

@FunctionalInterface
interface CipherDirLoader {

CiphertextDirectory load() throws IOException;
}

}
109 changes: 0 additions & 109 deletions src/test/java/org/cryptomator/cryptofs/CryptoPathMapperTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,115 +64,6 @@ public void setup() {
Mockito.when(fileSystem.getEmptyPath()).thenReturn(empty);
}

@Test
@DisplayName("Removing a cached cleartext path also removes all cached child paths")
public void testInvalidatingCleartextPathCleansCacheFromChildPaths() throws IOException {
//prepare root
Path d00 = Mockito.mock(Path.class);
Mockito.when(dataRoot.resolve("00")).thenReturn(d00);
Mockito.when(fileNameCryptor.hashDirectoryId("")).thenReturn("0000");

//prepare cleartextDir "/foo"
Path d0000 = Mockito.mock(Path.class, "d/00/00");
Path d0000oof = Mockito.mock(Path.class, "d/00/00/oof.c9r");
Path d0000oofdir = Mockito.mock(Path.class, "d/00/00/oof.c9r/dir.c9r");
Mockito.when(d00.resolve("00")).thenReturn(d0000);
Mockito.when(d0000.resolve("oof.c9r")).thenReturn(d0000oof);
Mockito.when(d0000oof.resolve("dir.c9r")).thenReturn(d0000oofdir);
Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.eq("foo"), Mockito.any())).thenReturn("oof");
Mockito.when(dirIdProvider.load(d0000oofdir)).thenReturn("1");
Mockito.when(fileNameCryptor.hashDirectoryId("1")).thenReturn("0001");

//prepare cleartextDir "/foo/bar"
Path d0001 = Mockito.mock(Path.class, "d/00/01");
Path d0001rab = Mockito.mock(Path.class, "d/00/01/rab.c9r");
Path d0000rabdir = Mockito.mock(Path.class, "d/00/00/rab.c9r/dir.c9r");
Mockito.when(d00.resolve("01")).thenReturn(d0001);
Mockito.when(d0001.resolve("rab.c9r")).thenReturn(d0001rab);
Mockito.when(d0001rab.resolve("dir.c9r")).thenReturn(d0000rabdir);
Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.eq("bar"), Mockito.any())).thenReturn("rab");
Mockito.when(dirIdProvider.load(d0000rabdir)).thenReturn("2");
Mockito.when(fileNameCryptor.hashDirectoryId("2")).thenReturn("0002");

Path d0002 = Mockito.mock(Path.class);
Mockito.when(d00.resolve("02")).thenReturn(d0002);

CryptoPathMapper mapper = new CryptoPathMapper(pathToVault, cryptor, dirIdProvider, longFileNameProvider, vaultConfig);
//put cleartextpath /foo
Path cipherFooPath = mapper.getCiphertextDir(fileSystem.getPath("/foo")).path;
//put cleartextpath /foo/bar
Path cipherFooBarPath = mapper.getCiphertextDir(fileSystem.getPath("/foo/bar")).path;
//invalidate /foo
mapper.invalidatePathMapping(fileSystem.getPath("/foo"));
//cache should miss
var mapperSpy = Mockito.spy(mapper);
mapperSpy.getCiphertextDir(fileSystem.getPath("/foo/bar"));
Mockito.verify(mapperSpy, Mockito.atLeast(1)).getCiphertextFilePath(Mockito.any());
}

@Test
@DisplayName("Moving a cached cleartext path also remaps all cached child paths")
public void testMovingCleartextPathRemapsCachedChildPaths() throws IOException {
CryptoPath fooPath = fileSystem.getPath("/foo");
CryptoPath fooBarPath = fileSystem.getPath("/foo/bar");
CryptoPath unkelFooPath = fileSystem.getPath("/unkel/foo");
CryptoPath unkelFooBarPath = fileSystem.getPath("/unkel/foo/bar");
//prepare root
Path d00 = Mockito.mock(Path.class);
Mockito.when(dataRoot.resolve("00")).thenReturn(d00);
Mockito.when(fileNameCryptor.hashDirectoryId("")).thenReturn("0000");

//prepare cleartextDir "/foo"
Path d0000 = Mockito.mock(Path.class, "d/00/00");
Path d0000oof = Mockito.mock(Path.class, "d/00/00/oof.c9r");
Path d0000oofdir = Mockito.mock(Path.class, "d/00/00/oof.c9r/dir.c9r");
Mockito.when(d00.resolve("00")).thenReturn(d0000);
Mockito.when(d0000.resolve("oof.c9r")).thenReturn(d0000oof);
Mockito.when(d0000oof.resolve("dir.c9r")).thenReturn(d0000oofdir);
Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.eq("foo"), Mockito.any())).thenReturn("oof");
Mockito.when(dirIdProvider.load(d0000oofdir)).thenReturn("1");
Mockito.when(fileNameCryptor.hashDirectoryId("1")).thenReturn("0001");

//prepare cleartextDir "/foo/bar"
Path d0001 = Mockito.mock(Path.class, "d/00/01");
Path d0001rab = Mockito.mock(Path.class, "d/00/01/rab.c9r");
Path d0000rabdir = Mockito.mock(Path.class, "d/00/00/rab.c9r/dir.c9r");
Mockito.when(d00.resolve("01")).thenReturn(d0001);
Mockito.when(d0001.resolve("rab.c9r")).thenReturn(d0001rab);
Mockito.when(d0001rab.resolve("dir.c9r")).thenReturn(d0000rabdir);
Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.eq("bar"), Mockito.any())).thenReturn("rab");
Mockito.when(dirIdProvider.load(d0000rabdir)).thenReturn("2");
Mockito.when(fileNameCryptor.hashDirectoryId("2")).thenReturn("0002");

Path d0002 = Mockito.mock(Path.class);
Mockito.when(d00.resolve("02")).thenReturn(d0002);

CryptoPathMapper mapper = new CryptoPathMapper(pathToVault, cryptor, dirIdProvider, longFileNameProvider, vaultConfig);
//put cleartextpath /foo in cache
Path cipherFooPath = mapper.getCiphertextDir(fooPath).path;
//put cleartextpath /foo/bar in cache
Path cipherBarPath = mapper.getCiphertextDir(fooBarPath).path;
//move /foo to /unkel/dinkel/foo/, effectively moving also moving /foo/bar
mapper.movePathMapping(fooPath, unkelFooPath);

//cache should ...
var mapperSpy = Mockito.spy(mapper);
var someCiphertextFilePath = Mockito.mock(CiphertextFilePath.class);
var someCiphertextDirFilePath = Mockito.mock(Path.class);
var someCipherDirObj = Mockito.mock(CryptoPathMapper.CiphertextDirectory.class);
Mockito.doReturn(someCiphertextFilePath).when(mapperSpy).getCiphertextFilePath(fooBarPath);
Mockito.doReturn(someCiphertextDirFilePath).when(someCiphertextFilePath).getDirFilePath();
Mockito.doReturn(someCipherDirObj).when(mapperSpy).resolveDirectory(someCiphertextDirFilePath);

//... succeed for /unkel/foo/ and /unkel/foo/bar
mapperSpy.getCiphertextDir(unkelFooPath);
mapperSpy.getCiphertextDir(unkelFooBarPath);
Mockito.verify(mapperSpy, Mockito.never()).getCiphertextFilePath(Mockito.any());

//...miss and return our mocked cipherDirObj
var actualCipherDirObj = mapperSpy.getCiphertextDir(fooBarPath);
Assertions.assertEquals(someCipherDirObj, actualCipherDirObj);
}

@Test
public void testPathEncryptionForRoot() throws IOException {
Expand Down

0 comments on commit 3515794

Please sign in to comment.