Skip to content

Commit 16f899a

Browse files
authored
cf: verify hash of downloaded mod files and repair (#435)
1 parent c8d1a24 commit 16f899a

File tree

6 files changed

+162
-42
lines changed

6 files changed

+162
-42
lines changed

src/main/java/me/itzg/helpers/curseforge/CurseForgeInstaller.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import java.nio.file.Files;
1414
import java.nio.file.Path;
1515
import java.nio.file.Paths;
16+
import java.time.Duration;
1617
import java.util.Arrays;
1718
import java.util.Collection;
1819
import java.util.Collections;
@@ -57,6 +58,7 @@
5758
import reactor.core.publisher.Flux;
5859
import reactor.core.publisher.Mono;
5960
import reactor.core.scheduler.Schedulers;
61+
import reactor.util.retry.Retry;
6062

6163
@RequiredArgsConstructor
6264
@Slf4j
@@ -70,6 +72,8 @@ public class CurseForgeInstaller {
7072
public static final String REPO_SUBDIR_MODPACKS = "modpacks";
7173
public static final String REPO_SUBDIR_MODS = "mods";
7274
public static final String REPO_SUBDIR_WORLDS = "worlds";
75+
private static final Duration BAD_FILE_DELAY = Duration.ofSeconds(5);
76+
public static final int BAD_FILE_ATTEMPTS = 3;
7377

7478
private final Path outputDir;
7579
private final Path resultsFile;
@@ -734,7 +738,21 @@ else if (category.getSlug().equals("worlds")) {
734738
);
735739

736740
final Mono<ResolveResult> resolvedFileMono =
737-
downloadOrResolveFile(context, modInfo, isWorld, outputDir, cfFile);
741+
Mono.defer(() ->
742+
downloadOrResolveFile(context, modInfo, isWorld, outputDir, cfFile)
743+
)
744+
// retry the deferred part above if one of the expected failure cases
745+
.retryWhen(
746+
Retry.fixedDelay(BAD_FILE_ATTEMPTS, BAD_FILE_DELAY)
747+
.filter(throwable ->
748+
throwable instanceof FileHashInvalidException ||
749+
throwable instanceof FailedRequestException
750+
)
751+
.doBeforeRetry(retrySignal ->
752+
log.warn("Retrying to download {} @ {}:{}",
753+
cfFile.getFileName(), projectID, fileID)
754+
)
755+
);
738756

739757
return isWorld ?
740758
resolvedFileMono
@@ -780,10 +798,12 @@ private Mono<ResolveResult> downloadOrResolveFile(InstallContext context, CurseF
780798

781799
if (locatedFile != null) {
782800
log.info("Mod file {} already exists", locatedFile);
783-
return Mono.just(new ResolveResult(locatedFile));
801+
return FileHashVerifier.verify(locatedFile, cfFile.getHashes())
802+
.map(ResolveResult::new);
784803
}
785804
else {
786805
return context.cfApi.download(cfFile, outputFile, modFileDownloadStatusHandler(this.outputDir, log))
806+
.flatMap(path -> FileHashVerifier.verify(path, cfFile.getHashes()))
787807
.map(ResolveResult::new)
788808
.onErrorResume(
789809
e -> e instanceof FailedRequestException
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package me.itzg.helpers.curseforge;
2+
3+
public class FileHashInvalidException extends RuntimeException {
4+
5+
public FileHashInvalidException(String message) {
6+
super(message);
7+
}
8+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package me.itzg.helpers.curseforge;
2+
3+
import java.nio.file.Files;
4+
import java.nio.file.Path;
5+
import java.util.EnumMap;
6+
import java.util.List;
7+
import java.util.Map;
8+
import lombok.extern.slf4j.Slf4j;
9+
import me.itzg.helpers.curseforge.model.FileHash;
10+
import me.itzg.helpers.curseforge.model.HashAlgo;
11+
import me.itzg.helpers.files.ChecksumAlgo;
12+
import me.itzg.helpers.files.Checksums;
13+
import reactor.core.publisher.Mono;
14+
import reactor.core.scheduler.Schedulers;
15+
16+
@Slf4j
17+
public class FileHashVerifier {
18+
19+
private final static Map<HashAlgo, ChecksumAlgo> algos = new EnumMap<>(HashAlgo.class);
20+
21+
static {
22+
algos.put(HashAlgo.Md5, ChecksumAlgo.MD5);
23+
algos.put(HashAlgo.Sha1, ChecksumAlgo.SHA1);
24+
}
25+
26+
public static Mono<Path> verify(Path file, List<FileHash> hashes) {
27+
for (final FileHash hash : hashes) {
28+
final ChecksumAlgo checksumAlgo = algos.get(hash.getAlgo());
29+
if (checksumAlgo != null) {
30+
return Mono.fromCallable(() -> {
31+
log.debug("Verifying hash of {}", file);
32+
33+
if (!Checksums.valid(file, checksumAlgo, hash.getValue())) {
34+
Files.delete(file);
35+
throw new FileHashInvalidException("Incorrect checksum: " + file);
36+
}
37+
else {
38+
return file;
39+
}
40+
})
41+
.subscribeOn(Schedulers.boundedElastic());
42+
}
43+
}
44+
45+
return Mono.error(new IllegalArgumentException("Unable to find compatible checksum algorithm"));
46+
}
47+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package me.itzg.helpers.files;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public enum ChecksumAlgo {
7+
MD5("md5", "MD5"),
8+
SHA1("sha1", "SHA-1"),
9+
SHA256("sha256", "SHA-256"),;
10+
11+
private final String prefix;
12+
13+
private final String jdkAlgo;
14+
15+
ChecksumAlgo(String prefix, String jdkAlgo) {
16+
this.prefix = prefix;
17+
this.jdkAlgo = jdkAlgo;
18+
}
19+
}

src/main/java/me/itzg/helpers/files/Checksums.java

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,65 +6,33 @@
66
import java.nio.file.Path;
77
import java.security.MessageDigest;
88
import java.security.NoSuchAlgorithmException;
9-
import java.util.HashMap;
10-
import java.util.Map;
11-
import me.itzg.helpers.errors.GenericException;
129
import org.apache.commons.codec.binary.Hex;
10+
import org.jetbrains.annotations.Blocking;
1311

1412
public class Checksums {
1513

16-
public static final String DEFAULT_PREFIX = "sha256";
17-
18-
private static final Map<String/*prefix*/, String/*algo*/> prefixAlgos = new HashMap<>();
19-
private static final String DELIMITER = ":";
20-
21-
static {
22-
prefixAlgos.put("md5", "MD5");
23-
prefixAlgos.put("sha1", "SHA-1");
24-
prefixAlgos.put("sha256", "SHA-256");
25-
}
26-
2714
private Checksums() {
2815
}
2916

30-
/**
31-
*
32-
* @param otherChecksum a checksum previously calculated with this method or null to use the default type
33-
* @param path a file to process
34-
* @return the file's checksum with a prefix tracking type
35-
*/
36-
public static String checksumLike(String otherChecksum, Path path) throws IOException {
37-
final String algo;
38-
final String prefix;
39-
if (otherChecksum != null) {
40-
final String[] parts = otherChecksum.split(DELIMITER, 2);
41-
algo = prefixAlgos.get(parts[0]);
42-
if (algo == null) {
43-
throw new GenericException("Unexpected checksum prefix: " + parts[0]);
44-
}
45-
prefix = parts[0];
46-
}
47-
else {
48-
prefix = DEFAULT_PREFIX;
49-
algo = prefixAlgos.get(DEFAULT_PREFIX);
50-
}
51-
17+
@Blocking
18+
public static boolean valid(Path file, ChecksumAlgo algo, String expectedCheckum) throws IOException {
5219
final MessageDigest md;
5320
try {
54-
md = MessageDigest.getInstance(algo);
21+
md = MessageDigest.getInstance(algo.getJdkAlgo());
5522
} catch (NoSuchAlgorithmException e) {
5623
throw new IllegalStateException(e);
5724
}
5825

59-
try (InputStream inputStream = Files.newInputStream(path)) {
26+
try (InputStream inputStream = Files.newInputStream(file)) {
6027
final byte[] buffer = new byte[1024];
6128
int len;
6229
while ((len = inputStream.read(buffer)) >= 0) {
6330
md.update(buffer, 0, len);
6431
}
6532
}
66-
6733
final byte[] digest = md.digest();
68-
return prefix + DELIMITER + Hex.encodeHexString(digest);
34+
35+
return expectedCheckum.toLowerCase().equals(Hex.encodeHexString(digest));
6936
}
37+
7038
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package me.itzg.helpers.curseforge;
2+
3+
import static java.util.Collections.singletonList;
4+
import static org.assertj.core.api.Assertions.assertThatCode;
5+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
6+
7+
import java.io.IOException;
8+
import java.nio.charset.StandardCharsets;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import me.itzg.helpers.curseforge.model.FileHash;
12+
import me.itzg.helpers.curseforge.model.HashAlgo;
13+
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.io.TempDir;
15+
16+
@SuppressWarnings("CodeBlock2Expr")
17+
class FileHashVerifierTest {
18+
19+
@Test
20+
void validMd5(@TempDir Path tempDir) throws Exception {
21+
final Path file = Files.write(tempDir.resolve("hello.txt"), "Hello World".getBytes(StandardCharsets.UTF_8));
22+
23+
assertThatCode(() -> {
24+
FileHashVerifier.verify(file, singletonList(
25+
new FileHash().setAlgo(HashAlgo.Md5).setValue("B10A8DB164E0754105B7A99BE72E3FE5")
26+
))
27+
.block();
28+
})
29+
.doesNotThrowAnyException();
30+
}
31+
32+
@Test
33+
void validSha1(@TempDir Path tempDir) throws IOException {
34+
final Path file = Files.write(tempDir.resolve("hello.txt"), "Hello World".getBytes(StandardCharsets.UTF_8));
35+
36+
assertThatCode(() -> {
37+
FileHashVerifier.verify(file, singletonList(
38+
new FileHash().setAlgo(HashAlgo.Sha1).setValue("0A4D55A8D778E5022FAB701977C5D840BBC486D0")
39+
))
40+
.block();
41+
})
42+
.doesNotThrowAnyException();
43+
44+
}
45+
46+
@Test
47+
void handlesInvalid(@TempDir Path tempDir) throws IOException {
48+
final Path file = Files.write(tempDir.resolve("hello.txt"), "Hello World".getBytes(StandardCharsets.UTF_8));
49+
50+
assertThatThrownBy(() -> {
51+
FileHashVerifier.verify(file, singletonList(
52+
new FileHash().setAlgo(HashAlgo.Md5).setValue("BAD")
53+
))
54+
.block();
55+
})
56+
.isInstanceOf(FileHashInvalidException.class);
57+
}
58+
}

0 commit comments

Comments
 (0)