Skip to content
This repository was archived by the owner on Mar 14, 2025. It is now read-only.

Commit f0aa33b

Browse files
committed
Merge branch 'release/0.3.0' into main
2 parents ef4e030 + ceeebb3 commit f0aa33b

17 files changed

+706
-210
lines changed

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<modelVersion>4.0.0</modelVersion>
66
<groupId>org.cryptomator</groupId>
77
<artifactId>cloud-access</artifactId>
8-
<version>0.2.0</version>
8+
<version>0.3.0</version>
99

1010
<name>Cryptomator CloudAccess in Java</name>
1111
<description>CloudAccess is used in e.g. Cryptomator for Android to access different cloud providers.</description>

src/main/java/org/cryptomator/cloudaccess/CloudAccess.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
import java.nio.file.Path;
1414
import java.security.NoSuchAlgorithmException;
1515
import java.security.SecureRandom;
16-
import java.util.stream.Collectors;
17-
import java.util.stream.IntStream;
1816

1917
public class CloudAccess {
2018

@@ -37,7 +35,7 @@ public static CloudProvider vaultFormat8GCMCloudAccess(CloudProvider cloudProvid
3735
// TODO validate vaultFormat.jwt before creating decorator
3836
VaultFormat8ProviderDecorator provider = new VaultFormat8ProviderDecorator(cloudProvider, pathToVault.resolve("d"), cryptor);
3937
provider.initialize();
40-
return provider;
38+
return new MetadataCachingProviderDecorator(provider);
4139
} catch (NoSuchAlgorithmException e) {
4240
throw new IllegalStateException("JVM doesn't supply a CSPRNG", e);
4341
} catch (InterruptedException e) {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package org.cryptomator.cloudaccess;
2+
3+
import com.google.common.cache.Cache;
4+
import com.google.common.cache.CacheBuilder;
5+
import org.cryptomator.cloudaccess.api.CloudItemList;
6+
import org.cryptomator.cloudaccess.api.CloudItemMetadata;
7+
import org.cryptomator.cloudaccess.api.CloudPath;
8+
import org.cryptomator.cloudaccess.api.CloudProvider;
9+
import org.cryptomator.cloudaccess.api.ProgressListener;
10+
import org.cryptomator.cloudaccess.api.exceptions.NotFoundException;
11+
12+
import java.io.InputStream;
13+
import java.time.Duration;
14+
import java.time.Instant;
15+
import java.util.Optional;
16+
import java.util.concurrent.CompletableFuture;
17+
import java.util.concurrent.CompletionStage;
18+
19+
public class MetadataCachingProviderDecorator implements CloudProvider {
20+
21+
final Cache<CloudPath, Optional<CloudItemMetadata>> metadataCache;
22+
private final CloudProvider delegate;
23+
24+
public MetadataCachingProviderDecorator(CloudProvider delegate) {
25+
this(delegate, Duration.ofSeconds(10));
26+
}
27+
28+
public MetadataCachingProviderDecorator(CloudProvider delegate, Duration cacheEntryMaxAge) {
29+
this.delegate = delegate;
30+
this.metadataCache = CacheBuilder.newBuilder().expireAfterWrite(cacheEntryMaxAge).build();
31+
}
32+
33+
@Override
34+
public CompletionStage<CloudItemMetadata> itemMetadata(CloudPath node) {
35+
var cachedMetadata = metadataCache.getIfPresent(node);
36+
if (cachedMetadata != null) {
37+
return cachedMetadata //
38+
.map(CompletableFuture::completedFuture) //
39+
.orElseGet(() -> CompletableFuture.failedFuture(new NotFoundException()));
40+
} else {
41+
return delegate.itemMetadata(node) //
42+
.whenComplete((metadata, exception) -> {
43+
if (exception == null) {
44+
assert metadata != null;
45+
metadataCache.put(node, Optional.of(metadata));
46+
} else if (exception instanceof NotFoundException) {
47+
metadataCache.put(node, Optional.empty());
48+
} else {
49+
metadataCache.invalidate(node);
50+
}
51+
});
52+
}
53+
}
54+
55+
@Override
56+
public CompletionStage<CloudItemList> list(CloudPath folder, Optional<String> pageToken) {
57+
return delegate.list(folder, pageToken) //
58+
.whenComplete((cloudItemList, exception) -> {
59+
evictIncludingDescendants(folder);
60+
if (exception == null) {
61+
assert cloudItemList != null;
62+
cloudItemList.getItems().forEach(metadata -> metadataCache.put(metadata.getPath(), Optional.of(metadata)));
63+
}
64+
});
65+
}
66+
67+
@Override
68+
public CompletionStage<InputStream> read(CloudPath file, ProgressListener progressListener) {
69+
return delegate.read(file, progressListener) //
70+
.whenComplete((metadata, exception) -> {
71+
if (exception != null) {
72+
metadataCache.invalidate(file);
73+
}
74+
});
75+
}
76+
77+
@Override
78+
public CompletionStage<InputStream> read(CloudPath file, long offset, long count, ProgressListener progressListener) {
79+
return delegate.read(file, offset, count, progressListener) //
80+
.whenComplete((inputStream, exception) -> {
81+
if (exception != null) {
82+
metadataCache.invalidate(file);
83+
}
84+
});
85+
}
86+
87+
@Override
88+
public CompletionStage<Void> write(CloudPath file, boolean replace, InputStream data, long size, Optional<Instant> lastModified, ProgressListener progressListener) {
89+
return delegate.write(file, replace, data, size, lastModified, progressListener) //
90+
.whenComplete((nullReturn, exception) -> {
91+
if (exception != null) {
92+
metadataCache.invalidate(file);
93+
}
94+
});
95+
}
96+
97+
@Override
98+
public CompletionStage<CloudPath> createFolder(CloudPath folder) {
99+
return delegate.createFolder(folder) //
100+
.whenComplete((metadata, exception) -> {
101+
metadataCache.invalidate(folder);
102+
});
103+
}
104+
105+
@Override
106+
public CompletionStage<Void> delete(CloudPath node) {
107+
return delegate.delete(node) //
108+
.whenComplete((nullReturn, exception) -> {
109+
evictIncludingDescendants(node);
110+
});
111+
}
112+
113+
@Override
114+
public CompletionStage<CloudPath> move(CloudPath source, CloudPath target, boolean replace) {
115+
return delegate.move(source, target, replace) //
116+
.whenComplete((path, exception) -> {
117+
metadataCache.invalidate(source);
118+
metadataCache.invalidate(target);
119+
});
120+
}
121+
122+
private void evictIncludingDescendants(CloudPath cleartextPath) {
123+
for (var path : metadataCache.asMap().keySet()) {
124+
if (path.startsWith(cleartextPath)) {
125+
metadataCache.invalidate(path);
126+
}
127+
}
128+
}
129+
}

src/main/java/org/cryptomator/cloudaccess/api/CloudProvider.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.cryptomator.cloudaccess.api.exceptions.CloudProviderException;
55

66
import java.io.InputStream;
7+
import java.time.Instant;
78
import java.util.Optional;
89
import java.util.concurrent.CompletableFuture;
910
import java.util.concurrent.CompletionStage;
@@ -107,7 +108,7 @@ default CompletionStage<InputStream> read(CloudPath file, ProgressListener progr
107108
CompletionStage<InputStream> read(CloudPath file, long offset, long count, ProgressListener progressListener);
108109

109110
/**
110-
* Writes to a given file, creating it if it doesn't exist yet.
111+
* Writes to a given file, creating it if it doesn't exist yet. <code>lastModified</code> is applied with best-effort but without guarantee.
111112
* <p>
112113
* The returned CompletionStage might complete exceptionally with one of the following exceptions:
113114
* <ul>
@@ -121,10 +122,11 @@ default CompletionStage<InputStream> read(CloudPath file, ProgressListener progr
121122
* @param replace Flag indicating whether to overwrite the file if it already exists.
122123
* @param data A data source from which to copy contents to the remote file
123124
* @param size The size of data
125+
* @param lastModified The lastModified which should be provided to the server
124126
* @param progressListener TODO Future use
125-
* @return CompletionStage that will be completed after writing all <code>data</code> and holds the new metadata of the item referenced by <code>file</code>.
127+
* @return CompletionStage that will be completed after writing all <code>data</code>.
126128
*/
127-
CompletionStage<CloudItemMetadata> write(CloudPath file, boolean replace, InputStream data, long size, ProgressListener progressListener);
129+
CompletionStage<Void> write(CloudPath file, boolean replace, InputStream data, long size, Optional<Instant> lastModified, ProgressListener progressListener);
128130

129131
/**
130132
* Create a folder. Does not create any potentially missing parent directories.

src/main/java/org/cryptomator/cloudaccess/localfs/LocalFsCloudProvider.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import java.nio.file.StandardCopyOption;
3232
import java.nio.file.StandardOpenOption;
3333
import java.nio.file.attribute.BasicFileAttributes;
34+
import java.nio.file.attribute.FileTime;
35+
import java.time.Instant;
3436
import java.util.ArrayList;
3537
import java.util.EnumSet;
3638
import java.util.List;
@@ -142,7 +144,7 @@ public CompletionStage<InputStream> read(CloudPath file, long offset, long count
142144
}
143145

144146
@Override
145-
public CompletionStage<CloudItemMetadata> write(CloudPath file, boolean replace, InputStream data, long size, ProgressListener progressListener) {
147+
public CompletionStage<Void> write(CloudPath file, boolean replace, InputStream data, long size, Optional<Instant> lastModified, ProgressListener progressListener) {
146148
Path filePath = resolve(file);
147149
var options = replace
148150
? EnumSet.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)
@@ -151,10 +153,12 @@ public CompletionStage<CloudItemMetadata> write(CloudPath file, boolean replace,
151153
Lock l = lock.writeLock();
152154
l.lock();
153155
try (var ch = FileChannel.open(filePath, options)) {
154-
var tmpSize = ch.transferFrom(Channels.newChannel(data), 0, Long.MAX_VALUE);
155-
var modifiedDate = Files.getLastModifiedTime(filePath).toInstant();
156-
var metadata = new CloudItemMetadata(file.getFileName().toString(), file, CloudItemType.FILE, Optional.of(modifiedDate), Optional.of(tmpSize));
157-
return CompletableFuture.completedFuture(metadata);
156+
var written = ch.transferFrom(Channels.newChannel(data), 0, Long.MAX_VALUE);
157+
assert size == written : "Written bytes should be equal to provided size";
158+
if(lastModified.isPresent()) {
159+
Files.setLastModifiedTime(filePath, FileTime.from(lastModified.get()));
160+
}
161+
return CompletableFuture.completedFuture(null);
158162
} catch (NoSuchFileException e) {
159163
return CompletableFuture.failedFuture(new NotFoundException(e));
160164
} catch (FileAlreadyExistsException e) {

src/main/java/org/cryptomator/cloudaccess/vaultformat8/VaultFormat8ProviderDecorator.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.nio.ByteBuffer;
2727
import java.nio.channels.Channels;
2828
import java.nio.charset.StandardCharsets;
29+
import java.time.Instant;
2930
import java.util.Optional;
3031
import java.util.UUID;
3132
import java.util.concurrent.CompletableFuture;
@@ -136,15 +137,15 @@ private long checkedAdd(long a, long b, long onOverflow) {
136137
}
137138

138139
@Override
139-
public CompletionStage<CloudItemMetadata> write(CloudPath file, boolean replace, InputStream data, long size, ProgressListener progressListener) {
140+
public CompletionStage<Void> write(CloudPath file, boolean replace, InputStream data, long size, Optional<Instant> lastModified, ProgressListener progressListener) {
140141
return getC9rPath(file).thenCompose(ciphertextPath -> {
141142
fileHeaderCache.evict(ciphertextPath);
142143
var src = Channels.newChannel(data);
143144
var encryptingChannel = new EncryptingReadableByteChannel(src, cryptor);
144145
var encryptedIn = Channels.newInputStream(encryptingChannel);
145146
long numBytes = Cryptors.ciphertextSize(size, cryptor) + cryptor.fileHeaderCryptor().headerSize();
146-
return delegate.write(ciphertextPath, replace, encryptedIn, numBytes, progressListener);
147-
}).thenApply(ciphertextMetadata -> toCleartextMetadata(ciphertextMetadata, file.getParent(), file.getFileName().toString()));
147+
return delegate.write(ciphertextPath, replace, encryptedIn, numBytes, lastModified, progressListener);
148+
});
148149
}
149150

150151
@Override
@@ -154,7 +155,7 @@ public CompletionStage<CloudPath> createFolder(CloudPath folder) {
154155

155156
var futureC9rFile = getC9rPath(folder)
156157
.thenCompose(delegate::createFolder)
157-
.thenCompose(folderPath -> delegate.write(folderPath.resolve(DIR_FILE_NAME), false, new ByteArrayInputStream(dirId), dirId.length, ProgressListener.NO_PROGRESS_AWARE));
158+
.thenCompose(folderPath -> delegate.write(folderPath.resolve(DIR_FILE_NAME), false, new ByteArrayInputStream(dirId), dirId.length, Optional.empty(), ProgressListener.NO_PROGRESS_AWARE));
158159

159160
var futureDir = delegate.createFolderIfNonExisting(dirPath.getParent())
160161
.thenCompose(unused -> delegate.createFolder(dirPath));
Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
package org.cryptomator.cloudaccess.webdav;
22

3-
import org.cryptomator.cloudaccess.api.CloudItemMetadata;
4-
import org.cryptomator.cloudaccess.api.CloudPath;
3+
import com.google.common.base.Splitter;
4+
import com.google.common.collect.Streams;
55

66
import java.net.URLDecoder;
77
import java.nio.charset.StandardCharsets;
88
import java.time.Instant;
99
import java.util.Optional;
1010
import java.util.regex.Pattern;
1111

12-
import static org.cryptomator.cloudaccess.api.CloudItemType.FILE;
13-
import static org.cryptomator.cloudaccess.api.CloudItemType.FOLDER;
14-
1512
class PropfindEntryData {
13+
1614
private static final Pattern URI_PATTERN = Pattern.compile("^[a-z]+://[^/]+/(.*)$");
1715

18-
private CloudPath path;
16+
private String path;
1917

20-
private boolean file = true;
18+
private boolean collection = true;
2119
private Optional<Instant> lastModified = Optional.empty();
2220
private Optional<Long> size = Optional.empty();
2321

@@ -32,52 +30,47 @@ private String extractPath(final String pathOrUri) {
3230
}
3331
}
3432

33+
private String urlDecode(final String value) {
34+
return URLDecoder.decode(value, StandardCharsets.UTF_8);
35+
}
36+
37+
public Optional<Instant> getLastModified() {
38+
return lastModified;
39+
}
40+
3541
void setLastModified(final Optional<Instant> lastModified) {
3642
this.lastModified = lastModified;
3743
}
3844

39-
public CloudPath getPath() {
45+
public String getPath() {
4046
return path;
4147
}
4248

43-
public void setPath(final String pathOrUri) {
44-
this.path = CloudPath.of(extractPath(pathOrUri));
49+
void setPath(final String pathOrUri) {
50+
this.path = extractPath(pathOrUri);
4551
}
4652

4753
public Optional<Long> getSize() {
4854
return size;
4955
}
5056

51-
public void setSize(final Optional<Long> size) {
57+
void setSize(final Optional<Long> size) {
5258
this.size = size;
5359
}
5460

55-
private boolean isFile() {
56-
return file;
61+
public boolean isCollection() {
62+
return collection;
5763
}
5864

59-
public void setFile(final boolean file) {
60-
this.file = file;
65+
void setCollection(final boolean collection) {
66+
this.collection = collection;
6167
}
6268

63-
public CloudItemMetadata toCloudItem() {
64-
if (isFile()) {
65-
return new CloudItemMetadata(getName(), path, FILE, lastModified, size);
66-
} else {
67-
return new CloudItemMetadata(getName(), path, FOLDER);
68-
}
69-
}
70-
71-
private String urlDecode(final String value) {
72-
return URLDecoder.decode(value, StandardCharsets.UTF_8);
73-
}
74-
75-
int getDepth() {
76-
return path.getNameCount();
69+
public long getDepth() {
70+
return Splitter.on("/").omitEmptyStrings().splitToStream(path).count();
7771
}
7872

79-
private String getName() {
80-
return path.getFileName().toString();
73+
public String getName() {
74+
return Streams.findLast(Splitter.on("/").omitEmptyStrings().splitToStream(path)).orElse("");
8175
}
82-
8376
}

src/main/java/org/cryptomator/cloudaccess/webdav/PropfindResponseParser.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,20 +121,20 @@ public void endElement(String uri, String localName, String qName) {
121121

122122
private void assembleEntry() {
123123
if (!status.contains(STATUS_OK)) {
124-
LOG.info("No propstat element with 200 status in response element. Entry ignored.");
124+
LOG.trace("No propstat element with 200 status in response element. Entry ignored.");
125125
return; // no-op
126126
}
127127

128128
if (href == null) {
129-
LOG.info("Missing href in response element. Entry ignored.");
129+
LOG.trace("Missing href in response element. Entry ignored.");
130130
return; // no-op
131131
}
132132

133133
var entry = new PropfindEntryData();
134134
entry.setLastModified(parseDate(lastModified));
135135
entry.setSize(parseLong(contentLength));
136136
entry.setPath(href);
137-
entry.setFile(!isCollection);
137+
entry.setCollection(isCollection);
138138

139139
entries.add(entry);
140140
}

0 commit comments

Comments
 (0)