Skip to content

Commit

Permalink
Downloader: Protect against partially downloaded files. (#954)
Browse files Browse the repository at this point in the history
* Downloader: Protect against partially downloaded files.

* Cleanup

* Add 1 minute timeout.

* Checkstyle
  • Loading branch information
modmuss50 authored Sep 22, 2023
1 parent 0b36121 commit bd09af1
Showing 1 changed file with 81 additions and 43 deletions.
124 changes: 81 additions & 43 deletions src/main/java/net/fabricmc/loom/util/download/Download.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@
public final class Download {
private static final String E_TAG = "ETag";
private static final Logger LOGGER = LoggerFactory.getLogger(Download.class);
private static final Duration TIMEOUT = Duration.ofMinutes(1);
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.ALWAYS)
.proxy(ProxySelector.getDefault())
.connectTimeout(TIMEOUT)
.build();

public static DownloadBuilder create(String url) throws URISyntaxException {
Expand Down Expand Up @@ -93,17 +95,20 @@ public static DownloadBuilder create(String url) throws URISyntaxException {
this.downloadAttempt = downloadAttempt;
}

private HttpRequest getRequest() {
private HttpRequest.Builder requestBuilder() {
return HttpRequest.newBuilder(url)
.timeout(TIMEOUT)
.version(httpVersion)
.GET()
.GET();
}

private HttpRequest getRequest() {
return requestBuilder()
.build();
}

private HttpRequest getETagRequest(String etag) {
return HttpRequest.newBuilder(url)
.version(httpVersion)
.GET()
return requestBuilder()
.header("If-None-Match", etag)
.build();
}
Expand Down Expand Up @@ -190,47 +195,12 @@ private void doDownload(Path output) throws DownloadException {
return;
}

if (success) {
try {
Files.deleteIfExists(output);
} catch (IOException e) {
throw error(e, "Failed to delete existing file");
}

final long length = Long.parseLong(response.headers().firstValue("Content-Length").orElse("-1"));
AtomicLong totalBytes = new AtomicLong(0);

try (OutputStream outputStream = Files.newOutputStream(output, StandardOpenOption.CREATE_NEW)) {
copyWithCallback(decodeOutput(response), outputStream, value -> {
if (length < 0) {
return;
}

progressListener.onProgress(totalBytes.addAndGet(value), length);
});
} catch (IOException e) {
throw error(e, "Failed to decode and write download output");
}

if (Files.notExists(output)) {
throw error("No file was downloaded");
}

if (length > 0) {
try {
final long actualLength = Files.size(output);

if (actualLength != length) {
throw error("Unexpected file length of %d bytes, expected %d bytes".formatted(actualLength, length));
}
} catch (IOException e) {
throw error(e);
}
}
} else {
if (!success) {
throw statusError("HTTP request returned unsuccessful status (%d)", statusCode);
}

downloadToPath(output, response);

if (useEtag) {
final HttpHeaders headers = response.headers();
final String responseETag = headers.firstValue(E_TAG.toLowerCase(Locale.ROOT)).orElse(null);
Expand Down Expand Up @@ -260,6 +230,58 @@ private void doDownload(Path output) throws DownloadException {
}
}

private void downloadToPath(Path output, HttpResponse<InputStream> response) throws DownloadException {
// Download the file initially to a .part file
final Path partFile = getPartFile(output);

try {
Files.deleteIfExists(output);
Files.deleteIfExists(partFile);
} catch (IOException e) {
throw error(e, "Failed to delete existing file");
}

final long length = Long.parseLong(response.headers().firstValue("Content-Length").orElse("-1"));
AtomicLong totalBytes = new AtomicLong(0);

try (OutputStream outputStream = Files.newOutputStream(partFile, StandardOpenOption.CREATE_NEW)) {
copyWithCallback(decodeOutput(response), outputStream, value -> {
if (length < 0) {
return;
}

progressListener.onProgress(totalBytes.addAndGet(value), length);
});
} catch (IOException e) {
throw error(e, "Failed to decode and write download output");
}

if (Files.notExists(partFile)) {
throw error("No file was downloaded");
}

if (length > 0) {
try {
final long actualLength = Files.size(partFile);

if (actualLength != length) {
throw error("Unexpected file length of %d bytes, expected %d bytes".formatted(actualLength, length));
}
} catch (IOException e) {
throw error(e);
}
}

try {
// Once the file has been fully read, create a hard link to the destination file.
// And then remove the temporary file, this ensures that the output file only exists in fully populated state.
Files.createLink(output, partFile);
Files.delete(partFile);
} catch (IOException e) {
throw error(e, "Failed to complete download");
}
}

private void copyWithCallback(InputStream is, OutputStream os, IntConsumer consumer) throws IOException {
byte[] buffer = new byte[1024];
int length;
Expand Down Expand Up @@ -389,6 +411,18 @@ private void tryCleanup(Path output) {
} catch (IOException ignored) {
// ignored
}

try {
Files.deleteIfExists(getLockFile(output));
} catch (IOException ignored) {
// ignored
}

try {
Files.deleteIfExists(getPartFile(output));
} catch (IOException ignored) {
// ignored
}
}

// A faster exists check
Expand All @@ -405,6 +439,10 @@ private Path getLockFile(Path output) {
return output.resolveSibling(output.getFileName() + ".lock");
}

private Path getPartFile(Path output) {
return output.resolveSibling(output.getFileName() + ".part");
}

private boolean getAndResetLock(Path output) throws DownloadException {
final Path lock = getLockFile(output);
final boolean exists = exists(lock);
Expand Down

0 comments on commit bd09af1

Please sign in to comment.