Skip to content

Commit a304bd0

Browse files
Armin Schrenkoverheadhunter
andauthored
Health-API: Implement OrphanDir.fix() (#106)
* introduce new directory with fixed dirId CRYPTOMATOR_RECOVERY * a stepparent dir is always created inside this dir * adopted files are suffixd with a random id constant over each fix() attempt * fix mockito to reflectively access certain classes (fixes #107) * remove ATOMIC_MOVE and manual code formatting Co-authored-by: Sebastian Stenzel <sebastian.stenzel@gmail.com>
1 parent d5be9a8 commit a304bd0

File tree

5 files changed

+527
-2
lines changed

5 files changed

+527
-2
lines changed

pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@
141141
<groupId>org.apache.maven.plugins</groupId>
142142
<artifactId>maven-surefire-plugin</artifactId>
143143
<version>3.0.0-M5</version>
144+
<configuration>
145+
<!-- Allow reflection for Mockito, so it can properly mock non-public classes -->
146+
<argLine>--add-opens=org.cryptomator.cryptofs/org.cryptomator.cryptofs.health.dirid=ALL-UNNAMED</argLine>
147+
</configuration>
144148
</plugin>
145149
<plugin>
146150
<groupId>org.apache.maven.plugins</groupId>

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ private Constants() {
1717
public static final String MASTERKEY_BACKUP_SUFFIX = ".bkup";
1818
public static final String DATA_DIR_NAME = "d";
1919
public static final String ROOT_DIR_ID = "";
20+
public static final String RECOVERY_DIR_ID = "recovery";
21+
2022
public static final String CRYPTOMATOR_FILE_SUFFIX = ".c9r";
2123
public static final String DEFLATED_FILE_SUFFIX = ".c9s";
2224
public static final String DIR_FILE_NAME = "dir.c9r";
@@ -29,4 +31,5 @@ private Constants() {
2931
public static final int MAX_DIR_FILE_LENGTH = 36; // UUIDv4: hex-encoded 16 byte int + 4 hyphens = 36 ASCII chars
3032

3133
public static final String SEPARATOR = "/";
34+
public static final String RECOVERY_DIR_NAME = "CRYPTOMATOR_RECOVERY";
3235
}

src/main/java/org/cryptomator/cryptofs/health/api/DiagnosticResult.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.cryptomator.cryptolib.api.Cryptor;
55
import org.cryptomator.cryptolib.api.Masterkey;
66

7+
import java.io.IOException;
78
import java.nio.file.Path;
89
import java.util.Map;
910

@@ -39,7 +40,7 @@ enum Severity {
3940
@Override
4041
String toString();
4142

42-
default void fix(Path pathToVault, VaultConfig config, Masterkey masterkey, Cryptor cryptor) {
43+
default void fix(Path pathToVault, VaultConfig config, Masterkey masterkey, Cryptor cryptor) throws IOException {
4344
throw new UnsupportedOperationException("Preliminary API not yet implemented");
4445
}
4546

src/main/java/org/cryptomator/cryptofs/health/dirid/OrphanDir.java

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
package org.cryptomator.cryptofs.health.dirid;
22

3+
import com.google.common.io.BaseEncoding;
4+
import org.cryptomator.cryptofs.CryptoPathMapper;
5+
import org.cryptomator.cryptofs.VaultConfig;
6+
import org.cryptomator.cryptofs.common.CiphertextFileType;
7+
import org.cryptomator.cryptofs.common.Constants;
38
import org.cryptomator.cryptofs.health.api.DiagnosticResult;
9+
import org.cryptomator.cryptolib.api.Cryptor;
10+
import org.cryptomator.cryptolib.api.FileNameCryptor;
11+
import org.cryptomator.cryptolib.api.Masterkey;
412

13+
import java.io.IOException;
14+
import java.nio.ByteBuffer;
15+
import java.nio.charset.StandardCharsets;
16+
import java.nio.file.FileAlreadyExistsException;
17+
import java.nio.file.Files;
18+
import java.nio.file.LinkOption;
519
import java.nio.file.Path;
20+
import java.nio.file.StandardOpenOption;
21+
import java.security.MessageDigest;
22+
import java.security.NoSuchAlgorithmException;
623
import java.util.Map;
24+
import java.util.UUID;
25+
import java.util.concurrent.atomic.AtomicInteger;
726

827
import static org.cryptomator.cryptofs.health.api.CommonDetailKeys.ENCRYPTED_PATH;
928

@@ -12,6 +31,11 @@
1231
*/
1332
public class OrphanDir implements DiagnosticResult {
1433

34+
private static final String FILE_PREFIX = "file";
35+
private static final String DIR_PREFIX = "directory";
36+
private static final String SYMLINK_PREFIX = "symlink";
37+
private static final String LONG_NAME_SUFFIX_BASE = "_withVeryLongName";
38+
1539
final Path dir;
1640

1741
OrphanDir(Path dir) {
@@ -33,5 +57,127 @@ public Map<String, String> details() {
3357
return Map.of(ENCRYPTED_PATH, dir.toString());
3458
}
3559

36-
// fix: create new dirId inside of L+F dir and rename existing dir accordingly.
60+
@Override
61+
public void fix(Path pathToVault, VaultConfig config, Masterkey masterkey, Cryptor cryptor) throws IOException {
62+
var sha1 = getSha1MessageDigest();
63+
String runId = Integer.toString((short) UUID.randomUUID().getMostSignificantBits(), 32);
64+
Path dataDir = pathToVault.resolve(Constants.DATA_DIR_NAME);
65+
Path orphanedDir = dataDir.resolve(this.dir);
66+
String orphanDirIdHash = dir.getParent().getFileName().toString() + dir.getFileName().toString();
67+
68+
Path recoveryDir = prepareRecoveryDir(pathToVault, cryptor.fileNameCryptor());
69+
if (recoveryDir.toAbsolutePath().equals(orphanedDir.toAbsolutePath())) {
70+
return; //recovery dir was orphaned, already recovered by prepare method
71+
}
72+
73+
var stepParentDir = prepareStepParent(dataDir, recoveryDir, cryptor.fileNameCryptor(), orphanDirIdHash);
74+
AtomicInteger fileCounter = new AtomicInteger(1);
75+
AtomicInteger dirCounter = new AtomicInteger(1);
76+
AtomicInteger symlinkCounter = new AtomicInteger(1);
77+
String longNameSuffix = createClearnameToBeShortened(config.getShorteningThreshold());
78+
try (var orphanedContentStream = Files.newDirectoryStream(orphanedDir)) {
79+
for (Path orphanedResource : orphanedContentStream) {
80+
//@formatter:off
81+
var newClearName = switch (determineCiphertextFileType(orphanedResource)) {
82+
case FILE -> FILE_PREFIX + fileCounter.getAndIncrement();
83+
case DIRECTORY -> DIR_PREFIX + dirCounter.getAndIncrement();
84+
case SYMLINK -> SYMLINK_PREFIX + symlinkCounter.getAndIncrement();
85+
} + "_" + runId;
86+
//@formatter:on
87+
adoptOrphanedResource(orphanedResource, newClearName, stepParentDir, cryptor.fileNameCryptor(), longNameSuffix, sha1);
88+
}
89+
}
90+
Files.delete(orphanedDir);
91+
}
92+
93+
//visible for testing
94+
Path prepareRecoveryDir(Path pathToVault, FileNameCryptor cryptor) throws IOException {
95+
Path dataDir = pathToVault.resolve(Constants.DATA_DIR_NAME);
96+
String rootDirHash = cryptor.hashDirectoryId(Constants.ROOT_DIR_ID);
97+
Path vaultCipherRootPath = dataDir.resolve(rootDirHash.substring(0, 2)).resolve(rootDirHash.substring(2)).toAbsolutePath();
98+
99+
//check if recovery dir exists and has unique recovery id
100+
String cipherRecoveryDirName = convertClearToCiphertext(cryptor, Constants.RECOVERY_DIR_NAME, Constants.ROOT_DIR_ID);
101+
Path cipherRecoveryDirFile = vaultCipherRootPath.resolve(cipherRecoveryDirName + "/" + Constants.DIR_FILE_NAME);
102+
if (Files.notExists(cipherRecoveryDirFile, LinkOption.NOFOLLOW_LINKS)) {
103+
Files.createDirectories(cipherRecoveryDirFile.getParent());
104+
Files.writeString(cipherRecoveryDirFile, Constants.RECOVERY_DIR_ID, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
105+
} else {
106+
String uuid = Files.readString(cipherRecoveryDirFile, StandardCharsets.UTF_8);
107+
if (!Constants.RECOVERY_DIR_ID.equals(uuid)) {
108+
throw new FileAlreadyExistsException("Directory /" + Constants.RECOVERY_DIR_NAME + " already exists, but with wrong directory id.");
109+
}
110+
}
111+
String recoveryDirHash = cryptor.hashDirectoryId(Constants.RECOVERY_DIR_ID);
112+
Path cipherRecoveryDir = dataDir.resolve(recoveryDirHash.substring(0, 2)).resolve(recoveryDirHash.substring(2)).toAbsolutePath();
113+
Files.createDirectories(cipherRecoveryDir);
114+
115+
return cipherRecoveryDir;
116+
}
117+
118+
// visible for testing
119+
CryptoPathMapper.CiphertextDirectory prepareStepParent(Path dataDir, Path cipherRecoveryDir, FileNameCryptor cryptor, String clearStepParentDirName) throws IOException {
120+
//create "step-parent" directory to move orphaned files to
121+
String cipherStepParentDirName = convertClearToCiphertext(cryptor, clearStepParentDirName, Constants.RECOVERY_DIR_ID);
122+
Path cipherStepParentDirFile = cipherRecoveryDir.resolve(cipherStepParentDirName + "/" + Constants.DIR_FILE_NAME);
123+
final String stepParentUUID;
124+
if (Files.exists(cipherStepParentDirFile, LinkOption.NOFOLLOW_LINKS)) {
125+
stepParentUUID = Files.readString(cipherStepParentDirFile, StandardCharsets.UTF_8);
126+
} else {
127+
Files.createDirectories(cipherStepParentDirFile.getParent());
128+
stepParentUUID = UUID.randomUUID().toString();
129+
Files.writeString(cipherStepParentDirFile, stepParentUUID, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
130+
}
131+
String stepParentDirHash = cryptor.hashDirectoryId(stepParentUUID);
132+
Path stepParentDir = dataDir.resolve(stepParentDirHash.substring(0, 2)).resolve(stepParentDirHash.substring(2)).toAbsolutePath();
133+
Files.createDirectories(stepParentDir);
134+
return new CryptoPathMapper.CiphertextDirectory(stepParentUUID, stepParentDir);
135+
}
136+
137+
// visible for testing
138+
void adoptOrphanedResource(Path oldCipherPath, String newClearname, CryptoPathMapper.CiphertextDirectory stepParentDir, FileNameCryptor cryptor, String longNameSuffix, MessageDigest sha1) throws IOException {
139+
if (oldCipherPath.toString().endsWith(Constants.DEFLATED_FILE_SUFFIX)) {
140+
var newCipherName = convertClearToCiphertext(cryptor, newClearname + longNameSuffix, stepParentDir.dirId);
141+
var deflatedName = BaseEncoding.base64Url().encode(sha1.digest(newCipherName.getBytes(StandardCharsets.UTF_8))) + Constants.DEFLATED_FILE_SUFFIX;
142+
Path targetPath = stepParentDir.path.resolve(deflatedName);
143+
Files.move(oldCipherPath, targetPath);
144+
145+
//adjust name.c9s
146+
try (var fc = Files.newByteChannel(targetPath.resolve(Constants.INFLATED_FILE_NAME), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
147+
fc.write(ByteBuffer.wrap(newCipherName.getBytes(StandardCharsets.UTF_8)));
148+
}
149+
} else {
150+
var newCipherName = convertClearToCiphertext(cryptor, newClearname, stepParentDir.dirId);
151+
Path targetPath = stepParentDir.path.resolve(newCipherName);
152+
Files.move(oldCipherPath, targetPath);
153+
}
154+
}
155+
156+
private static String createClearnameToBeShortened(int threshold) {
157+
int neededLength = (threshold - 4) / 4 * 3 - 16;
158+
return LONG_NAME_SUFFIX_BASE.repeat((neededLength % LONG_NAME_SUFFIX_BASE.length()) + 1);
159+
}
160+
161+
private static String convertClearToCiphertext(FileNameCryptor cryptor, String clearTextName, String dirId) {
162+
return cryptor.encryptFilename(BaseEncoding.base64Url(), clearTextName, dirId.getBytes(StandardCharsets.UTF_8)) + Constants.CRYPTOMATOR_FILE_SUFFIX;
163+
}
164+
165+
private static CiphertextFileType determineCiphertextFileType(Path ciphertextPath) {
166+
if (Files.exists(ciphertextPath.resolve(Constants.DIR_FILE_NAME), LinkOption.NOFOLLOW_LINKS)) {
167+
return CiphertextFileType.DIRECTORY;
168+
} else if (Files.exists(ciphertextPath.resolve(Constants.SYMLINK_FILE_NAME), LinkOption.NOFOLLOW_LINKS)) {
169+
return CiphertextFileType.SYMLINK;
170+
} else {
171+
return CiphertextFileType.FILE;
172+
}
173+
}
174+
175+
private static MessageDigest getSha1MessageDigest() {
176+
try {
177+
return MessageDigest.getInstance("SHA1");
178+
} catch (NoSuchAlgorithmException e) {
179+
throw new IllegalStateException("Every JVM needs to provide a SHA1 implementation.");
180+
}
181+
}
182+
37183
}

0 commit comments

Comments
 (0)