Skip to content

Commit a3433ab

Browse files
Download via .download temp file then rename (#627)
* Support optional --use-temp-dir in get to download to temp dir and copy, to avoid partial jar if interrupted * Change implementation to use adjacent temp file instead of temp directory * Implement adjacent temporary file download with proper error handling and comprehensive tests --------- Co-authored-by: Tom Abnett <[email protected]>
1 parent f0d51ec commit a3433ab

File tree

2 files changed

+158
-11
lines changed

2 files changed

+158
-11
lines changed

src/main/java/me/itzg/helpers/get/GetCommand.java

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ public class GetCommand implements Callable<Integer> {
134134
@Option(names = "--retry-delay", description = "in seconds", defaultValue = "2")
135135
int retryDelay;
136136

137+
@Option(names = {"--use-temp-file"},
138+
description = "Download to a temporary file in the same directory with .download extension, then rename to the final destination when complete",
139+
defaultValue = "false")
140+
boolean useTempFile;
141+
137142
@Parameters(split = OPTION_SPLIT_COMMAS, paramLabel = "URI",
138143
description = "The URI of the resource to retrieve. When the output is a directory,"
139144
+ " more than one URI can be requested.",
@@ -209,18 +214,36 @@ null, new JsonPathOutputHandler(
209214
stdout.println(outputFile);
210215
}
211216
} else {
212-
final Path file = fetch(uris.get(0))
213-
.toFile(outputFile)
214-
.skipUpToDate(skipUpToDate)
215-
.acceptContentTypes(acceptContentTypes)
216-
.handleDownloaded((uri, f, contentSizeBytes) -> {
217-
if (logProgressEach) {
218-
log.info("Downloaded {}", f);
217+
final Path saveToFile = getSaveToFile();
218+
try {
219+
final Path file = fetch(uris.get(0))
220+
.toFile(saveToFile)
221+
.skipUpToDate(skipUpToDate)
222+
.acceptContentTypes(acceptContentTypes)
223+
.handleDownloaded((uri, f, contentSizeBytes) -> {
224+
if (logProgressEach) {
225+
log.info("Downloaded {}", f);
226+
}
227+
})
228+
.execute();
229+
if (useTempFile) {
230+
Files.move(saveToFile, outputFile);
231+
}
232+
if (this.outputFilename) {
233+
stdout.println(file);
234+
}
235+
} catch (Exception e) {
236+
// Clean up temp file if download fails
237+
if (useTempFile && Files.exists(saveToFile)) {
238+
try {
239+
Files.delete(saveToFile);
240+
log.debug("Cleaned up temporary file {} after failed download", saveToFile);
241+
} catch (IOException cleanupEx) {
242+
log.warn("Failed to clean up temporary file {} after failed download",
243+
saveToFile, cleanupEx);
219244
}
220-
})
221-
.execute();
222-
if (this.outputFilename) {
223-
stdout.println(file);
245+
}
246+
throw e; // Re-throw the original exception
224247
}
225248
}
226249
}
@@ -477,5 +500,10 @@ private static URI removeUserInfo(URI uri) throws URISyntaxException {
477500
);
478501
}
479502

503+
private Path getSaveToFile() {
504+
return useTempFile ?
505+
Paths.get(outputFile.toString() + ".download") :
506+
outputFile;
507+
}
480508

481509
}

src/test/java/me/itzg/helpers/get/OutputToFileTest.java

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,123 @@ void skipsUpToDate_butDownloadsWhenAbsent(@TempDir Path tempDir) throws IOExcept
230230
assertThat(fileToDownload).hasContent("New content");
231231
}
232232

233+
@Test
234+
void successfulWithTemporaryFile(@TempDir Path tempDir) throws MalformedURLException, IOException {
235+
mock.expectRequest("GET", "/downloadsToFile.txt",
236+
response()
237+
.withBody("Response content to file", MediaType.TEXT_PLAIN)
238+
);
239+
240+
final Path expectedFile = tempDir.resolve("out.txt");
241+
242+
final int status =
243+
new CommandLine(new GetCommand())
244+
.execute(
245+
"-o",
246+
expectedFile.toString(),
247+
"--use-temp-file",
248+
mock.buildMockedUrl("/downloadsToFile.txt").toString()
249+
);
250+
251+
assertThat(status).isEqualTo(0);
252+
assertThat(expectedFile).exists();
253+
assertThat(expectedFile).hasContent("Response content to file");
254+
// The temporary file with .download extension should no longer exist after successful download
255+
assertThat(tempDir.resolve("out.txt.download")).doesNotExist();
256+
}
257+
258+
@Test
259+
void handlesExistingDownloadFile(@TempDir Path tempDir) throws MalformedURLException, IOException {
260+
mock.expectRequest("GET", "/downloadsToFile.txt",
261+
response()
262+
.withBody("New content", MediaType.TEXT_PLAIN)
263+
);
264+
265+
final Path expectedFile = tempDir.resolve("out.txt");
266+
final Path downloadFile = tempDir.resolve("out.txt.download");
267+
268+
// Create a pre-existing .download file with different content
269+
Files.writeString(downloadFile, "Partial old content");
270+
271+
final int status =
272+
new CommandLine(new GetCommand())
273+
.execute(
274+
"-o",
275+
expectedFile.toString(),
276+
"--use-temp-file",
277+
mock.buildMockedUrl("/downloadsToFile.txt").toString()
278+
);
279+
280+
assertThat(status).isEqualTo(0);
281+
assertThat(expectedFile).exists();
282+
assertThat(expectedFile).hasContent("New content");
283+
// The temporary file should be gone
284+
assertThat(downloadFile).doesNotExist();
285+
}
286+
287+
@Test
288+
void preservesOriginalWhenErrorOccurs(@TempDir Path tempDir) throws MalformedURLException, IOException {
289+
mock.expectRequest("GET", "/errorFile.txt",
290+
response()
291+
.withStatusCode(500)
292+
.withBody("Server error", MediaType.TEXT_PLAIN)
293+
);
294+
295+
final Path expectedFile = tempDir.resolve("out.txt");
296+
final String originalContent = "Original content that should be preserved";
297+
298+
// Create the original file with content that should remain untouched
299+
Files.writeString(expectedFile, originalContent);
300+
301+
final int status =
302+
new CommandLine(new GetCommand())
303+
.execute(
304+
"-o",
305+
expectedFile.toString(),
306+
"--use-temp-file",
307+
mock.buildMockedUrl("/errorFile.txt").toString()
308+
);
309+
310+
// Should fail with non-zero status
311+
assertThat(status).isNotEqualTo(0);
312+
// Original file should still exist with unchanged content
313+
assertThat(expectedFile).exists();
314+
assertThat(expectedFile).hasContent(originalContent);
315+
// Any temporary download file should be cleaned up
316+
assertThat(tempDir.resolve("out.txt.download")).doesNotExist();
317+
}
318+
319+
@Test
320+
void preservesOriginalWhenDownloadHasInvalidContent(@TempDir Path tempDir) throws MalformedURLException, IOException {
321+
// Set up a request that will result in an error during processing
322+
// We'll return content with a valid Content-Length but corrupted/truncated data
323+
mock.expectRequest("GET", "/interruptedFile.txt",
324+
response()
325+
.withHeader("Content-Length", "1000") // Much larger than actual content
326+
.withBody("This is only part of the expected content...") // Truncated content
327+
);
328+
329+
final Path expectedFile = tempDir.resolve("out.txt");
330+
final String originalContent = "Original content that should remain intact";
331+
332+
// Create the original file with content that should remain untouched
333+
Files.writeString(expectedFile, originalContent);
334+
335+
final int status =
336+
new CommandLine(new GetCommand())
337+
.execute(
338+
"-o",
339+
expectedFile.toString(),
340+
"--use-temp-file",
341+
mock.buildMockedUrl("/interruptedFile.txt").toString()
342+
);
343+
344+
// Original file should still exist with unchanged content
345+
assertThat(expectedFile).exists();
346+
assertThat(expectedFile).hasContent(originalContent);
347+
348+
// Any temporary download file should be cleaned up
349+
assertThat(tempDir.resolve("out.txt.download")).doesNotExist();
350+
}
351+
233352
}

0 commit comments

Comments
 (0)