Add file-transfer module: multipart limits, filename handling, Range downloads, streaming under -Xmx64m, S3 presigned URLs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VRLbHYdXVEBzGVhYq3pz4q
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package com.ankurm.filetransfer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import com.ankurm.filetransfer.Cap.Part;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class FilenameAndDownloadTest {
|
||||
|
||||
static final String F = "02-filename.txt";
|
||||
static final String G = "03-download.txt";
|
||||
|
||||
@LocalServerPort int port;
|
||||
@Autowired FileStore store;
|
||||
|
||||
@BeforeAll
|
||||
static void clean() {
|
||||
Cap.reset(F);
|
||||
Cap.reset(G);
|
||||
}
|
||||
|
||||
String base() {
|
||||
return "http://localhost:" + port;
|
||||
}
|
||||
|
||||
String postRaw(String filename, String contentType, byte[] content) throws Exception {
|
||||
String boundary = "----ft" + System.nanoTime();
|
||||
byte[] body = Cap.multipart(boundary, List.of(new Part("file", filename, contentType, content)));
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(base() + "/files"))
|
||||
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
|
||||
return Cap.HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
|
||||
}
|
||||
|
||||
@Test
|
||||
void whatTheServerSeesIsWhatTheClientSent() throws Exception {
|
||||
String evil = "../../etc/cron.d/evil.txt";
|
||||
String json = postRaw(evil, "image/png", "hello".getBytes(StandardCharsets.UTF_8));
|
||||
Cap.say(F, "client sent filename=" + evil + " Content-Type=image/png");
|
||||
Cap.say(F, "server response: " + json);
|
||||
|
||||
Path root = store.root();
|
||||
boolean escapes = !root.resolve(evil).normalize().startsWith(root);
|
||||
Cap.say(F, "root.resolve(originalFilename).normalize() stays inside the storage dir: " + !escapes);
|
||||
assertThat(json).contains("\"displayName\":\"evil.txt\"");
|
||||
assertThat(escapes).isTrue();
|
||||
|
||||
String win = postRaw("..\\..\\windows\\system32\\evil.dll", "text/plain", "x".getBytes(StandardCharsets.UTF_8));
|
||||
Cap.say(F, "client sent a Windows-style path; server response: " + win);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadSupportsRangeAndEncodesNames() throws Exception {
|
||||
String json = postRaw("résumé 2026.txt", "text/plain", "ABCDEFGHIJKLMNOPQRSTUVWXYZ".getBytes(StandardCharsets.UTF_8));
|
||||
String id = json.replaceAll(".*\"id\":\"([^\"]+)\".*", "$1");
|
||||
|
||||
HttpResponse<String> full = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id)).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
Cap.say(G, "GET /files/{id}");
|
||||
Cap.say(G, " status=" + full.statusCode() + " body=" + full.body());
|
||||
for (String h : List.of("content-type", "content-length", "content-disposition", "accept-ranges", "x-content-type-options")) {
|
||||
Cap.say(G, " " + h + ": " + full.headers().firstValue(h).orElse("(absent)"));
|
||||
}
|
||||
|
||||
HttpResponse<String> part = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id))
|
||||
.header("Range", "bytes=10-19").build(), HttpResponse.BodyHandlers.ofString());
|
||||
Cap.say(G, "GET /files/{id} with Range: bytes=10-19");
|
||||
Cap.say(G, " status=" + part.statusCode() + " body=" + part.body());
|
||||
Cap.say(G, " content-range: " + part.headers().firstValue("content-range").orElse("(absent)"));
|
||||
|
||||
HttpResponse<String> past = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id))
|
||||
.header("Range", "bytes=500-").build(), HttpResponse.BodyHandlers.ofString());
|
||||
Cap.say(G, "GET /files/{id} with Range: bytes=500-");
|
||||
Cap.say(G, " status=" + past.statusCode() + " content-range: " + past.headers().firstValue("content-range").orElse("(absent)"));
|
||||
|
||||
assertThat(part.statusCode()).isEqualTo(206);
|
||||
assertThat(part.body()).isEqualTo("KLMNOPQRST");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user