diff --git a/scripts/rufh_conformity_test.py b/scripts/rufh_conformity_test.py index 3cb05f07..705bdd4a 100755 --- a/scripts/rufh_conformity_test.py +++ b/scripts/rufh_conformity_test.py @@ -111,7 +111,7 @@ def parse_headers(lines): return res_headers -def http_request(method, url, headers=None, body=None, test_name=""): +def http_request(method, url, headers=None, body=None, test_name="", chunked=False): """ Socket-based HTTP client helper that transparently handles HTTP 104 interim response frames. Tracks 104 interim response detection in INTERIM_RESPONSES_DETECTED. @@ -130,19 +130,34 @@ def http_request(method, url, headers=None, body=None, test_name=""): if "Connection" not in req_headers: req_headers["Connection"] = "close" + is_chunked = chunked or (req_headers.get("Transfer-Encoding", "").lower() == "chunked") + if is_chunked: + req_headers["Transfer-Encoding"] = "chunked" + if "Content-Length" in req_headers: + del req_headers["Content-Length"] + req_lines = [f"{method} {path} HTTP/1.1", f"Host: {host}:{port}"] for k, v in req_headers.items(): req_lines.append(f"{k}: {v}") - if body is not None and "Content-Length" not in req_headers: + if body is not None and not is_chunked and "Content-Length" not in req_headers: body_len = len(body) if isinstance(body, bytes) else len(body.encode("utf-8")) req_lines.append(f"Content-Length: {body_len}") req_lines.append("") req_lines.append("") req_data = "\r\n".join(req_lines).encode("latin1") - if body: + if is_chunked: + if body: + body_bytes = body if isinstance(body, bytes) else body.encode("utf-8") + req_data += f"{len(body_bytes):X}\r\n".encode("latin1") + body_bytes + b"\r\n0\r\n\r\n" + else: + req_data += b"0\r\n\r\n" + elif body: req_data += body if isinstance(body, bytes) else body.encode("utf-8") - s.sendall(req_data) + try: + s.sendall(req_data) + except (BrokenPipeError, ConnectionResetError): + pass # Read response data resp_bytes = b"" @@ -873,6 +888,109 @@ def test_append_exceeding_max_append_size(self, target_url, request): assert status == 413, "Append exceeding max-append-size MUST be rejected with 413" assert UPLOAD_LIMIT in rej_headers, "413 rejection SHOULD include Upload-Limit" + def test_chunked_append_and_creation_enforce_max_append_size(self, target_url, request): + """ + §4.1.4, §4.7: Chunked Creation and Append Exceeding max-append-size Enforced by Storage Backend. + Quote: "The server MAY enforce limits on the size of individual data append requests." + Expected behavior: When Content-Length is omitted (Transfer-Encoding: chunked), storage streaming + enforces max-append-size on both creation POST and append PATCH, returning 413, preserving offset, + and accepting chunked appends within limit. + """ + _, resp_headers, _, _ = http_request("OPTIONS", target_url, test_name=request.node.name) + limit_hdr = resp_headers.get(UPLOAD_LIMIT, "") + max_append_size = None + min_append_size = None + for part in limit_hdr.split(","): + part = part.strip() + if "max-append-size=" in part: + max_append_size = int(part.split("=")[1].strip()) + elif "min-append-size=" in part: + min_append_size = int(part.split("=")[1].strip()) + if max_append_size is None or max_append_size > 10 * 1024 * 1024: + pytest.skip("Server max-append-size is not advertised or too large to test over socket") + + # Step 1: Chunked creation POST exceeding max-append-size + headers = { + UPLOAD_COMPLETE: FALSE, + } + oversized_body = b"X" * (max_append_size + 1) + status, rej_headers, _, _ = http_request("POST", target_url, headers=headers, body=oversized_body, chunked=True, test_name=request.node.name) + assert status == 413, f"Chunked creation exceeding max-append-size MUST be rejected with 413, got {status}" + + # Step 2: Create a clean partial upload without body and without declared length + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length=None) + + # Step 3: Chunked append PATCH exceeding max-append-size + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, rej_headers, _, _ = http_request("PATCH", upload_uri, headers=headers, body=oversized_body, chunked=True, test_name=request.node.name) + assert status == 413, f"Chunked append exceeding max-append-size MUST be rejected with 413, got {status}" + + # Step 4: Verify offset remains 0 after rejection + status, head_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert head_headers.get(UPLOAD_OFFSET) == "0", "Upload offset MUST remain 0 after rejected chunked append" + + # Step 5: Chunked append PATCH within limit succeeds + valid_size = min_append_size if (min_append_size and min_append_size <= max_append_size) else min(max_append_size, 16) + valid_body = b"Y" * valid_size + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=valid_body, chunked=True, test_name=request.node.name) + assert status in (200, 204), f"Chunked append within max-append-size MUST succeed, got {status}" + status, head_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert head_headers.get(UPLOAD_OFFSET) == str(valid_size), f"Upload offset MUST be {valid_size} after valid chunked append" + + def test_chunked_append_and_creation_enforce_min_append_size(self, target_url, request): + """ + §4.1.4: Chunked Creation and Append Below min-append-size Enforced by Storage Backend. + Quote: "min-append-size: An Integer indicating the minimum number of bytes that MUST be appended + with each upload append request. This limit does not apply to upload creation requests with no content, + or to requests completing the upload by including the Upload-Complete: ?1 header field." + Expected behavior: When Content-Length is omitted (Transfer-Encoding: chunked), storage streaming + enforces min-append-size on creation POST and append PATCH (when Upload-Complete: ?0), returning 400/413, + preserving offset, and accepting chunked appends meeting min-append-size. + """ + _, resp_headers, _, _ = http_request("OPTIONS", target_url, test_name=request.node.name) + limit_hdr = resp_headers.get(UPLOAD_LIMIT, "") + min_append_size = None + for part in limit_hdr.split(","): + if "min-append-size=" in part: + min_append_size = int(part.split("=")[1].strip()) + if min_append_size is None or min_append_size <= 1: + pytest.skip("Server does not advertise min-append-size > 1") + + # Step 1: Chunked creation POST below min-append-size with content + headers = { + UPLOAD_COMPLETE: FALSE, + } + small_body = b"x" * (min_append_size - 1) + status, rej_headers, _, _ = http_request("POST", target_url, headers=headers, body=small_body, chunked=True, test_name=request.node.name) + assert status in (400, 413), f"Chunked creation below min-append-size MUST be rejected with 400/413, got {status}" + + # Step 2: Create a clean partial upload without body and without declared length + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length=None) + + # Step 3: Chunked append PATCH below min-append-size without completion + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, rej_headers, _, _ = http_request("PATCH", upload_uri, headers=headers, body=small_body, chunked=True, test_name=request.node.name) + assert status in (400, 413), f"Chunked append below min-append-size MUST be rejected with 400/413, got {status}" + + # Step 4: Verify offset remains 0 after rejection + status, head_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert head_headers.get(UPLOAD_OFFSET) == "0", "Upload offset MUST remain 0 after rejected chunked append" + + # Step 5: Chunked append PATCH meeting min-append-size succeeds + valid_body = b"y" * min_append_size + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=valid_body, chunked=True, test_name=request.node.name) + assert status in (200, 204), f"Chunked append meeting min-append-size MUST succeed, got {status}" + status, head_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert head_headers.get(UPLOAD_OFFSET) == str(min_append_size), f"Upload offset MUST be {min_append_size} after valid chunked append" + class TestUploadResourceDeactivation: """Tests for Upload Resource Deactivation (§4.5, §4.4.2).""" diff --git a/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandler.java b/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandler.java index d453470c..3c1e0695 100644 --- a/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandler.java +++ b/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandler.java @@ -13,8 +13,6 @@ import me.desair.tus.server.util.InterruptibleInputStream; import me.desair.tus.server.util.TusServletRequest; import me.desair.tus.server.util.TusServletResponse; -import me.desair.tus.server.util.Utils; -import org.apache.commons.lang3.Strings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,12 +38,9 @@ public HttpProblemDetails process( TusException exception) throws IOException, TusException { - Long contentLength = Utils.getLongHeader(servletRequest, HttpHeader.CONTENT_LENGTH); - // Allow chunked transfer encoding where Content-Length is omitted or negative - boolean hasContent = - (contentLength != null && contentLength > 0) - || Strings.CI.contains( - servletRequest.getHeader(HttpHeader.TRANSFER_ENCODING), "chunked"); + // Check if the creation request carries upload data to append in the initial request body. + // Supports requests with Content-Length as well as chunked encoding or HTTP/2 streaming. + boolean hasContent = servletRequest != null && servletRequest.hasContent(); if (hasContent) { String location = servletResponse.getHeader(HttpHeader.LOCATION); if (location != null) { diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java index 991bc758..9ca0442c 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java @@ -16,7 +16,6 @@ import me.desair.tus.server.util.TusServletRequest; import me.desair.tus.server.util.TusServletResponse; import me.desair.tus.server.util.Utils; -import org.apache.commons.lang3.Strings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -100,49 +99,48 @@ public HttpProblemDetails process( // Per RUFH §4.1.4: "This limit does not apply to upload creation requests with no content, // or to requests completing the upload by including the Upload-Complete: ?1 header field." - // An empty creation request carries no body (contentLength <= 0 without chunked encoding). - // In servlet requests without a body, getContentLengthLong() returns -1. We must verify - // that actual payload content is present before invoking storageService.append(); otherwise, - // sending a 0-byte stream to backends with minAppendSize configured (e.g. S3 or Azure Blob) - // would trigger MinAppendSizeNotMetException on an empty creation request. - boolean hasContent = - contentLength > 0 - || (contentLength < 0 - && servletRequest.getHeader(HttpHeader.TRANSFER_ENCODING) != null - && Strings.CI.contains( - servletRequest.getHeader(HttpHeader.TRANSFER_ENCODING), "chunked")); - - InputStream is = servletRequest.getContentInputStream(); + // An empty creation request carries no body. In requests without Content-Length (e.g. + // HTTP/1.1 chunked encoding, HTTP/2 streaming, or upstream proxies stripping hop-by-hop + // headers per RFC 9112 §6.3), getContentLengthLong() returns -1. + // servletRequest.hasContent() peeks 1 byte via PushbackInputStream to accurately detect + // whether actual payload bytes are present before invoking storageService.append(); + // otherwise, passing an empty stream to backends with minAppendSize would erroneously reject an + // empty creation request. + boolean hasContent = servletRequest != null && servletRequest.hasContent(); + InterruptibleInputStream interruptibleStream = null; - if (is != null && hasContent) { - interruptibleStream = new InterruptibleInputStream(is); - uploadLockingService.registerInputStream(uploadUri, interruptibleStream); - is = interruptibleStream; - try { - UploadInfo appended = uploadStorageService.append(uploadInfo, is); - if (appended != null) { - uploadInfo = appended; - } - } catch (IOException e) { - // When an upload stream is interrupted by the locking service watchdog or a concurrent - // lock contention release request (e.g. from a concurrent HEAD or DELETE), the storage - // backend (Disk, S3, Azure) commits all bytes received up to the interruption and updates - // the offset in storage. We reload the updated UploadInfo and acknowledge the partial - // upload rather than propagating an unhandled error to the servlet container. - if (interruptibleStream != null && interruptibleStream.isInterrupted()) { - // Refresh UploadInfo first so the log statement and response reflect the true committed - // byte offset persisted by the storage backend up to the interruption point. - UploadInfo refreshed = uploadStorageService.getUploadInfo(uploadUri, ownerKey); - if (refreshed != null) { - uploadInfo = refreshed; + if (hasContent) { + InputStream is = servletRequest.getContentInputStream(); + if (is != null) { + interruptibleStream = new InterruptibleInputStream(is); + uploadLockingService.registerInputStream(uploadUri, interruptibleStream); + is = interruptibleStream; + try { + UploadInfo appended = uploadStorageService.append(uploadInfo, is); + if (appended != null) { + uploadInfo = appended; + } + } catch (IOException e) { + // When an upload stream is interrupted by the locking service watchdog or a concurrent + // lock contention release request (e.g. from a concurrent HEAD or DELETE), the storage + // backend (Disk, S3, Azure) commits all bytes received up to the interruption and updates + // the offset in storage. We reload the updated UploadInfo and acknowledge the partial + // upload rather than propagating an unhandled error to the servlet container. + if (interruptibleStream != null && interruptibleStream.isInterrupted()) { + // Refresh UploadInfo first so the log statement and response reflect the true committed + // byte offset persisted by the storage backend up to the interruption point. + UploadInfo refreshed = uploadStorageService.getUploadInfo(uploadUri, ownerKey); + if (refreshed != null) { + uploadInfo = refreshed; + } + log.info( + "RUFH creation request with body for URI {} was interrupted by locking service" + + " contention; saved partial upload up to offset {}", + uploadUri, + uploadInfo != null ? uploadInfo.getOffset() : "unknown"); + } else { + throw e; } - log.info( - "RUFH creation request with body for URI {} was interrupted by locking service" - + " contention; saved partial upload up to offset {}", - uploadUri, - uploadInfo != null ? uploadInfo.getOffset() : "unknown"); - } else { - throw e; } } } diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java index 030933f8..165788f0 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java @@ -154,11 +154,14 @@ public void validate( // Section 4.1.4: min-append-size validation with exemption for Upload-Complete: ?1 // "This limit does not apply to upload creation requests with no content, or to requests // completing the upload by including the Upload-Complete: ?1 header field." + // Only validate minAppendSize by Content-Length header when contentLength >= 0. For chunked + // requests (Transfer-Encoding: chunked, where contentLength is -1), payload size cannot be + // determined from headers alone and is enforced by the storage service append() method. Long minAppendSize = uploadStorageService.getMinAppendSize(); Boolean uploadComplete = StructuredHeaderUtil.parseBoolean(uploadCompleteHeader); boolean isCompleteExempt = Boolean.TRUE.equals(uploadComplete); - if (minAppendSize != null && minAppendSize > 0 && !isCompleteExempt) { + if (minAppendSize != null && minAppendSize > 0 && !isCompleteExempt && contentLength >= 0) { if (contentLength < minAppendSize) { throw new MinAppendSizeNotMetException( "The request payload size (" diff --git a/src/main/java/me/desair/tus/server/upload/azure/AzureBlobStorageService.java b/src/main/java/me/desair/tus/server/upload/azure/AzureBlobStorageService.java index d2fca39e..a3b546d9 100644 --- a/src/main/java/me/desair/tus/server/upload/azure/AzureBlobStorageService.java +++ b/src/main/java/me/desair/tus/server/upload/azure/AzureBlobStorageService.java @@ -247,8 +247,16 @@ public UploadInfo append(UploadInfo upload, InputStream inputStream) File chunkFile = null; boolean handedOff = false; try { + long maxBytesToRead = optimalBlockSize; + if (effectiveMaxAppendSize != null) { + // Read at most 1 byte beyond the remaining allowed append size so oversized streams + // are interrupted immediately without buffering large amounts of excess data to disk. + long remainingAllowed = Math.max(0L, effectiveMaxAppendSize - totalAppended); + maxBytesToRead = Math.min(optimalBlockSize, remainingAllowed + 1L); + } + chunkFile = Files.createTempFile(tempBufferDir, "tus-azure-chunk-", ".tmp").toFile(); - ReadChunkResult chunkResult = readChunk(inputStream, chunkFile, optimalBlockSize); + ReadChunkResult chunkResult = readChunk(inputStream, chunkFile, maxBytesToRead); long chunkSize = chunkResult.bytesRead; if (chunkResult.exception != null) { streamException = chunkResult.exception; @@ -258,18 +266,10 @@ public UploadInfo append(UploadInfo upload, InputStream inputStream) break; } - if (effectiveMaxAppendSize != null - && (totalAppended + chunkSize) > effectiveMaxAppendSize) { - // If maxAppendSize is exceeded, stop reading from the stream but commit - // previously staged blocks so no uploaded data or offset is corrupted. - pendingTusException = - new MaxAppendSizeExceededException( - "Append payload size " - + (totalAppended + chunkSize) - + " exceeded limit of " - + effectiveMaxAppendSize); - break; - } + // Validate append size against effectiveMaxAppendSize. If exceeded, throws + // MaxAppendSizeExceededException, which is caught below as pendingTusException to stop + // reading while still committing any previously staged blocks without data loss. + validateMaxAppendSize(totalAppended + chunkSize, effectiveMaxAppendSize); int plannedIndex = blockIds.size() + plannedBlockIds.size(); // Validate chunk against remaining block budget diff --git a/src/main/java/me/desair/tus/server/upload/disk/DiskStorageService.java b/src/main/java/me/desair/tus/server/upload/disk/DiskStorageService.java index 68f95e7c..18e5c609 100644 --- a/src/main/java/me/desair/tus/server/upload/disk/DiskStorageService.java +++ b/src/main/java/me/desair/tus/server/upload/disk/DiskStorageService.java @@ -21,6 +21,8 @@ import me.desair.tus.server.checksum.ChecksumAlgorithm; import me.desair.tus.server.checksum.ChecksumKey; import me.desair.tus.server.exception.InvalidUploadOffsetException; +import me.desair.tus.server.exception.MaxAppendSizeExceededException; +import me.desair.tus.server.exception.MinAppendSizeNotMetException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.exception.UploadNotFoundException; import me.desair.tus.server.upload.UploadId; @@ -354,7 +356,7 @@ public UploadInfo append(UploadInfo info, InputStream inputStream) long max = getMaxUploadSize() > 0 ? getMaxUploadSize() : Long.MAX_VALUE; long transferred = 0; - Long offset = info.getOffset(); + Long offset = info.getOffset() != null ? info.getOffset() : 0L; long newOffset = offset; try (ReadableByteChannel uploadedBytes = Channels.newChannel(inputStream); @@ -372,11 +374,16 @@ public UploadInfo append(UploadInfo info, InputStream inputStream) + " bytes. You can only append to the end of an upload"); } - // write all bytes in the channel up to the configured maximum in a loop to support - // transfers larger than 2GB (OS syscall limits) + // Write all bytes in the channel up to the configured maximum in a loop to support + // transfers larger than 2GB (OS syscall limits). If an explicit maxAppendSize limit is + // configured, allow reading at most maxAppendSize + 1 bytes so oversized transfers + // can be detected immediately without streaming unbounded excess bytes to disk. long bytesTransferred = 0; - while (offset + bytesTransferred < max) { - long toTransfer = max - (offset + bytesTransferred); + long appendLimit = this.maxAppendSize != null ? this.maxAppendSize + 1L : Long.MAX_VALUE; + + while (offset + bytesTransferred < max && bytesTransferred < appendLimit) { + long toTransfer = + Math.min(max - (offset + bytesTransferred), appendLimit - bytesTransferred); long n = file.transferFrom(uploadedBytes, offset + bytesTransferred, toTransfer); if (n <= 0) { break; @@ -387,6 +394,33 @@ public UploadInfo append(UploadInfo info, InputStream inputStream) file.force(true); newOffset = offset + transferred; + // If maxAppendSize was exceeded, truncate back to initial offset and throw + // MaxAppendSizeExceededException so no partial invalid bytes from this request are kept. + if (this.maxAppendSize != null && transferred > this.maxAppendSize) { + file.truncate(offset); + file.force(true); + newOffset = offset; + validateMaxAppendSize(transferred); + } + + // Validate minimum append size constraints if configured. + // Per RUFH §4.1.4: "This limit does not apply to upload creation requests with no + // content, or to requests completing the upload by including the Upload-Complete: ?1 + // header field." + boolean isCompletingOrEmpty = + !info.isUploadInProgress() + || (info.getLength() != null && newOffset >= info.getLength()); + if (!isCompletingOrEmpty + && this.minAppendSize != null + && transferred < this.minAppendSize) { + file.truncate(offset); + file.force(true); + newOffset = offset; + validateMinAppendSize(transferred); + } + + } catch (TusException te) { + throw te; } catch (Exception ex) { // An error occurred, try to write as much data as possible newOffset = writeAsMuchAsPossible(file); @@ -707,4 +741,38 @@ private long writeAsMuchAsPossible(FileChannel file) throws IOException { } return offset; } + + /** + * Validates that the number of bytes appended during the current request does not exceed the + * configured {@code maxAppendSize}. + * + * @param totalAppended The number of bytes appended during this request + * @throws MaxAppendSizeExceededException If the appended size exceeds the maximum limit + */ + void validateMaxAppendSize(long totalAppended) throws MaxAppendSizeExceededException { + if (this.maxAppendSize != null && totalAppended > this.maxAppendSize) { + throw new MaxAppendSizeExceededException( + "Append size " + + totalAppended + + " exceeds maximum allowed chunk size of " + + this.maxAppendSize); + } + } + + /** + * Validates that the number of bytes appended during the current request meets the configured + * {@code minAppendSize}. + * + * @param totalAppended The number of bytes appended during this request + * @throws MinAppendSizeNotMetException If the appended size is below the minimum limit + */ + void validateMinAppendSize(long totalAppended) throws MinAppendSizeNotMetException { + if (this.minAppendSize != null && totalAppended < this.minAppendSize) { + throw new MinAppendSizeNotMetException( + "Append size " + + totalAppended + + " is less than minimum allowed of " + + this.minAppendSize); + } + } } diff --git a/src/main/java/me/desair/tus/server/util/TusServletRequest.java b/src/main/java/me/desair/tus/server/util/TusServletRequest.java index 37b0bd49..39267949 100644 --- a/src/main/java/me/desair/tus/server/util/TusServletRequest.java +++ b/src/main/java/me/desair/tus/server/util/TusServletRequest.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequestWrapper; import java.io.IOException; import java.io.InputStream; +import java.io.PushbackInputStream; import java.security.DigestInputStream; import java.security.MessageDigest; import java.util.Arrays; @@ -32,6 +33,7 @@ public class TusServletRequest extends HttpServletRequestWrapper { private Map> trailerHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); private Set processedBySet = new TreeSet<>(); + private Boolean hasContent = null; /** * Constructs a request object wrapping the given request. @@ -59,6 +61,9 @@ public TusServletRequest(HttpServletRequest request) { public InputStream getContentInputStream() throws IOException { if (contentInputStream == null) { contentInputStream = super.getInputStream(); + if (contentInputStream == null) { + return null; + } // If we're dealing with chunked transfer encoding, // abstract it so that the rest of our code doesn't need to care @@ -124,6 +129,68 @@ public Set getEnabledChecksums() { return digestInputStreamMap.keySet(); } + /** + * Determine whether this request carries body content to be uploaded or appended. + * + *

If {@code Content-Length} is positive, returns {@code true}. If {@code Content-Length} is + * zero, returns {@code false}. When {@code Content-Length} is absent or negative (e.g. HTTP/1.1 + * chunked transfer encoding, HTTP/2 streaming, or upstream proxies stripping hop-by-hop headers + * per RFC 9112 §6.3), peeks 1 byte via {@link PushbackInputStream} to verify whether body bytes + * are present without consuming them from the stream. + * + * @return true if the request contains at least one byte of body content, false otherwise + * @throws IOException if an error occurs reading from the input stream + */ + public boolean hasContent() throws IOException { + if (hasContent != null) { + return hasContent; + } + + boolean isChunked = hasChunkedTransferEncoding(); + long contentLength = -1L; + if (!isChunked) { + contentLength = getContentLengthLong(); + if (contentLength < 0) { + String clHeader = getHeader(HttpHeader.CONTENT_LENGTH); + if (StringUtils.isNotBlank(clHeader)) { + try { + contentLength = Long.parseLong(clHeader.trim()); + } catch (NumberFormatException ignored) { + contentLength = -1L; + } + } + } + } + if (contentLength > 0) { + hasContent = Boolean.TRUE; + return true; + } else if (contentLength == 0) { + hasContent = Boolean.FALSE; + return false; + } + + InputStream is = getContentInputStream(); + if (is == null) { + hasContent = Boolean.FALSE; + return false; + } + + if (!(is instanceof PushbackInputStream)) { + is = new PushbackInputStream(is); + contentInputStream = is; + } + PushbackInputStream pushbackStream = (PushbackInputStream) is; + int firstByte = pushbackStream.read(); + if (firstByte != -1) { + pushbackStream.unread(firstByte); + hasContent = Boolean.TRUE; + return true; + } + + hasContent = Boolean.FALSE; + return false; + } + @Override public String getHeader(String name) { String value = super.getHeader(name); diff --git a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java index 3c402982..5fa5b978 100644 --- a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java +++ b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java @@ -95,6 +95,28 @@ protected void reset() { servletResponse = new MockHttpServletResponse(); } + /** + * Resets mock HTTP request and response objects simulating chunked transfer encoding where the + * Content-Length header is omitted (getContentLengthLong returns -1L). + */ + protected void resetChunked() { + servletRequest = + new MockHttpServletRequest() { + @Override + public int getContentLength() { + return -1; + } + + @Override + public long getContentLengthLong() { + return -1L; + } + }; + servletRequest.setRemoteAddr("192.168.1.1"); + servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked"); + servletResponse = new MockHttpServletResponse(); + } + // =============================================================================================== // USE CASE 1: OPTIONS Discovery // =============================================================================================== @@ -914,6 +936,161 @@ public void testCompletingAppendPatchBypassesMinAppendSize() throws Exception { } } + /** + * Section 4.1.4 (Limits - max-append-size): "max-append-size: An Integer indicating the maximum + * number of bytes that can be appended with each upload append request." Section 4.7 (Upload + * Append - Payload Limits): "The server MAY enforce limits on the size of individual data append + * requests." + * + *

Use Case: Configure max-append-size (10 bytes) on TusFileUploadService. Verify that chunked + * streaming requests without a Content-Length header (Transfer-Encoding: chunked) are properly + * validated and enforced by the storage backend's append method across all storage backends + * (Disk, S3, Azure Blob) during both creation POST and append PATCH, returning HTTP 413. + */ + @Test + public void testChunkedAppendAndCreationEnforceMaxAppendSize() throws Exception { + tusFileUploadService.withMaxAppendSize(10L); + + // Step 1: Chunked creation POST exceeding maxAppendSize (25 bytes > 10L) + resetChunked(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + byte[] oversizedPayload = "1234567890123456789012345".getBytes(StandardCharsets.UTF_8); + servletRequest.setContent(oversizedPayload); + + UploadInfo failedCreation = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertNull(failedCreation); + assertResponseStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + + // Step 2: Create a clean upload without content + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100"); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(createdInfo); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // Step 3: Chunked append PATCH exceeding maxAppendSize (25 bytes > 10L) + resetChunked(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.setContent(oversizedPayload); + + UploadInfo failedAppend = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertNull(failedAppend); + assertResponseStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + + // Offset on server must remain at 0 after rejection + UploadInfo currentInfo = tusFileUploadService.getUploadInfo(uploadLocation, OWNER_KEY); + assertNotNull(currentInfo); + assertEquals(Long.valueOf(0L), currentInfo.getOffset()); + + // Step 4: Chunked append PATCH within maxAppendSize (8 bytes <= 10L) succeeds + byte[] validPayload = "12345678".getBytes(StandardCharsets.UTF_8); + resetChunked(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.setContent(validPayload); + + UploadInfo validAppend = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertNotNull(validAppend); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertEquals(Long.valueOf(8L), validAppend.getOffset()); + } + + /** + * Section 4.1.4 (Limits - min-append-size): "min-append-size: An Integer indicating the minimum + * number of bytes that MUST be appended with each upload append request. This limit does not + * apply to upload creation requests with no content, or to requests completing the upload by + * including the Upload-Complete: ?1 header field." + * + *

Use Case: Configure min-append-size (20 bytes) on TusFileUploadService. Verify that chunked + * streaming requests without a Content-Length header (Transfer-Encoding: chunked) are rejected + * with HTTP 400 when below min-append-size, while chunked payloads meeting or exceeding the limit + * succeed without premature validator rejection. + */ + @Test + public void testChunkedAppendAndCreationEnforceMinAppendSize() throws Exception { + tusFileUploadService.withMinAppendSize(20L); + + // Step 1: Chunked creation POST below minAppendSize (8 bytes < 20L) is rejected + resetChunked(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + byte[] smallPayload = "12345678".getBytes(StandardCharsets.UTF_8); + servletRequest.setContent(smallPayload); + + UploadInfo failedCreation = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertNull(failedCreation); + assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST); + + // Step 2: Create a clean upload without content + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100"); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(createdInfo); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // Step 3: Chunked append PATCH below minAppendSize (8 bytes < 20L) without completion is + // rejected + resetChunked(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.setContent(smallPayload); + + UploadInfo failedAppend = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertNull(failedAppend); + assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST); + + // Offset on server must remain at 0 after rejection + UploadInfo currentInfo = tusFileUploadService.getUploadInfo(uploadLocation, OWNER_KEY); + assertNotNull(currentInfo); + assertEquals(Long.valueOf(0L), currentInfo.getOffset()); + + // Step 4: Chunked append PATCH meeting minAppendSize (25 bytes >= 20L) succeeds + byte[] validPayload = "1234567890123456789012345".getBytes(StandardCharsets.UTF_8); + resetChunked(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.setContent(validPayload); + + UploadInfo validAppend = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertNotNull(validAppend); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertEquals(Long.valueOf(25L), validAppend.getOffset()); + } + @Test public void testRufhUploadCancellationWithDeduplication() throws Exception { tusFileUploadService.withUploadDeduplication(true); diff --git a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java index 33ec1b3a..e6d05908 100644 --- a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java +++ b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java @@ -2402,6 +2402,63 @@ public void testUploadCompletionListenerTusCreationWithUpload() throws Exception tusFileUploadService.deleteUpload(completedUploadInfo.get()); } + @Test + public void testCreationWithUploadChunkedTransferEncoding() throws Exception { + String uploadContent = "chunked-creation-with-upload-bytes"; + String chunkedPayload = + String.format( + "%x\r\n%s\r\n0\r\n\r\n", + uploadContent.getBytes(StandardCharsets.UTF_8).length, uploadContent); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked"); + servletRequest.setContent(chunkedPayload.getBytes(StandardCharsets.UTF_8)); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(createdInfo); + assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(uploadContent.length())); + + String location = servletResponse.getHeader(HttpHeader.LOCATION); + assertNotNull(location); + + try (InputStream stream = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + + tusFileUploadService.deleteUpload(createdInfo); + } + + @Test + public void testCreationWithUploadChunkedEmptyBody() throws Exception { + String emptyChunkedPayload = "0\r\n\r\n"; + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked"); + servletRequest.setContent(emptyChunkedPayload.getBytes(StandardCharsets.UTF_8)); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(createdInfo); + assertResponseHeaderNull(HttpHeader.UPLOAD_OFFSET); + + String location = servletResponse.getHeader(HttpHeader.LOCATION); + assertNotNull(location); + UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY); + assertNotNull(info); + assertThat(info.getOffset(), is(0L)); + + tusFileUploadService.deleteUpload(createdInfo); + } + @Test public void testUploadCompletionListenerTusConcatenation() throws Exception { String part1Content = "part1-"; diff --git a/src/test/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandlerTest.java b/src/test/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandlerTest.java index bb7b882e..dac31b03 100644 --- a/src/test/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandlerTest.java @@ -47,6 +47,7 @@ public void testProcessWithLockingService() throws Exception { UploadLockingService lockingService = mock(UploadLockingService.class); when(request.getHeader(HttpHeader.CONTENT_LENGTH)).thenReturn("5"); + when(request.hasContent()).thenReturn(true); when(response.getHeader(HttpHeader.LOCATION)).thenReturn("/files/123"); when(request.getContentInputStream()).thenReturn(new ByteArrayInputStream("hello".getBytes())); @@ -78,6 +79,7 @@ public void testProcessWithoutLockingService() throws Exception { UploadStorageService storageService = mock(UploadStorageService.class); when(request.getHeader(HttpHeader.CONTENT_LENGTH)).thenReturn("5"); + when(request.hasContent()).thenReturn(true); when(response.getHeader(HttpHeader.LOCATION)).thenReturn("/files/123"); when(request.getContentInputStream()).thenReturn(new ByteArrayInputStream("hello".getBytes())); @@ -112,6 +114,7 @@ public void testProcessInterruptedByLockingServiceContention() throws Exception UploadLockingService lockingService = mock(UploadLockingService.class); when(request.getHeader(HttpHeader.CONTENT_LENGTH)).thenReturn("100"); + when(request.hasContent()).thenReturn(true); when(response.getHeader(HttpHeader.LOCATION)).thenReturn("/files/interrupted-cwu"); when(request.getContentInputStream()).thenReturn(new ByteArrayInputStream("data".getBytes())); @@ -152,6 +155,7 @@ public void testProcessUninterruptedIoExceptionRethrown() throws Exception { UploadLockingService lockingService = mock(UploadLockingService.class); when(request.getHeader(HttpHeader.CONTENT_LENGTH)).thenReturn("100"); + when(request.hasContent()).thenReturn(true); when(response.getHeader(HttpHeader.LOCATION)).thenReturn("/files/cwu-error"); when(request.getContentInputStream()).thenReturn(new ByteArrayInputStream("data".getBytes())); @@ -166,4 +170,75 @@ public void testProcessUninterruptedIoExceptionRethrown() throws Exception { handler.process( HttpMethod.POST, request, response, storageService, lockingService, "owner", null); } + + @Test + public void testProcessChunkedWithContent() throws Exception { + TusServletRequest request = mock(TusServletRequest.class); + TusServletResponse response = mock(TusServletResponse.class); + UploadStorageService storageService = mock(UploadStorageService.class); + UploadLockingService lockingService = mock(UploadLockingService.class); + + when(request.getHeader(HttpHeader.CONTENT_LENGTH)).thenReturn(null); + when(request.hasContent()).thenReturn(true); + when(response.getHeader(HttpHeader.LOCATION)).thenReturn("/files/chunked-cwu"); + when(request.getContentInputStream()) + .thenReturn(new ByteArrayInputStream("chunked-bytes".getBytes())); + + UploadInfo uploadInfo = new UploadInfo(); + uploadInfo.setLength(20L); + uploadInfo.setOffset(0L); + + UploadInfo updatedInfo = new UploadInfo(); + updatedInfo.setLength(20L); + updatedInfo.setOffset(13L); + + when(storageService.getUploadInfo("/files/chunked-cwu", "owner")).thenReturn(uploadInfo); + when(storageService.append(eq(uploadInfo), any(InputStream.class))).thenReturn(updatedInfo); + + handler.process( + HttpMethod.POST, request, response, storageService, lockingService, "owner", null); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(InterruptibleInputStream.class); + verify(lockingService).registerInputStream(eq("/files/chunked-cwu"), captor.capture()); + assertNotNull(captor.getValue()); + verify(response).setHeader(HttpHeader.UPLOAD_OFFSET, "13"); + } + + @Test + public void testProcessChunkedEmptyNoContent() throws Exception { + TusServletRequest request = mock(TusServletRequest.class); + TusServletResponse response = mock(TusServletResponse.class); + UploadStorageService storageService = mock(UploadStorageService.class); + UploadLockingService lockingService = mock(UploadLockingService.class); + + when(request.getHeader(HttpHeader.CONTENT_LENGTH)).thenReturn(null); + when(request.hasContent()).thenReturn(false); + + handler.process( + HttpMethod.POST, request, response, storageService, lockingService, "owner", null); + + // Verify storage append was never called for empty creation request + org.mockito.Mockito.verifyNoInteractions(storageService); + org.mockito.Mockito.verifyNoInteractions(lockingService); + org.mockito.Mockito.verify(response, org.mockito.Mockito.never()) + .setHeader(eq(HttpHeader.UPLOAD_OFFSET), any()); + } + + @Test + public void testProcessZeroContentLength() throws Exception { + TusServletRequest request = mock(TusServletRequest.class); + TusServletResponse response = mock(TusServletResponse.class); + UploadStorageService storageService = mock(UploadStorageService.class); + UploadLockingService lockingService = mock(UploadLockingService.class); + + when(request.getHeader(HttpHeader.CONTENT_LENGTH)).thenReturn("0"); + + handler.process( + HttpMethod.POST, request, response, storageService, lockingService, "owner", null); + + // Verify storage append was never called for 0 Content-Length + org.mockito.Mockito.verifyNoInteractions(storageService); + org.mockito.Mockito.verifyNoInteractions(lockingService); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java index 2e497a66..2dacb2db 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java @@ -570,6 +570,50 @@ public InputStream getContentInputStream() { assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); } + @Test + public void testProcessCreationWithChunkedEmptyBodyDoesNotCallAppend() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + request.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked"); + + TusServletRequest tusRequest = + new TusServletRequest(request) { + @Override + public long getContentLengthLong() { + return -1L; + } + + @Override + public InputStream getContentInputStream() { + return new java.io.ByteArrayInputStream(new byte[0]); + } + }; + + UploadInfo createdInfo = new UploadInfo(); + createdInfo.setId(new UploadId("chunked-empty-id")); + createdInfo.setOffset(0L); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))) + .thenReturn(createdInfo); + + handler.process( + HttpMethod.POST, + tusRequest, + new TusServletResponse(response), + storageService, + lockingService, + "owner", + null); + + // Verify storageService.append is NEVER invoked for empty chunked creation + verify(storageService, org.mockito.Mockito.never()) + .append(any(UploadInfo.class), any(InputStream.class)); + assertThat(response.getStatus(), is(201)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("0")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + } + @Test public void testProcessCreationWithBodyInterrupted() throws Exception { byte[] content = "partial-creation-data".getBytes(); diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java index e1f94825..0a1e6d44 100644 --- a/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java @@ -6,6 +6,7 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.MinAppendSizeNotMetException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; @@ -291,4 +292,119 @@ public void testValidateExceedingLengthTerminateThrowsException() throws Excepti validator.validate(HttpMethod.PATCH, request, storageService, "owner"); } + + /** + * Section 4.1.4 (min-append-size): "This limit does not apply to upload creation requests with no + * content, or to requests completing the upload by including the Upload-Complete: ?1 header + * field." + * + *

When Content-Length is omitted or unknown (contentLength == -1L, e.g. Transfer-Encoding: + * chunked), minAppendSize validation must not reject the request during header validation; + * payload size is enforced during streaming by the storage service append() method. + */ + @Test + public void testValidateMinAppendSizeWithChunkedTransferDoesNotThrow() throws Exception { + MockHttpServletRequest chunkedReq = + new MockHttpServletRequest() { + @Override + public int getContentLength() { + return -1; + } + + @Override + public long getContentLengthLong() { + return -1L; + } + }; + chunkedReq.setRequestURI("/files/exists"); + chunkedReq.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + chunkedReq.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + chunkedReq.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + chunkedReq.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked"); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + when(storageService.getMinAppendSize()).thenReturn(500L); + + validator.validate(HttpMethod.PATCH, chunkedReq, storageService, "owner"); + // KISS: verifying method executes cleanly without throwing MinAppendSizeNotMetException + org.junit.Assert.assertTrue(true); + } + + @Test(expected = MinAppendSizeNotMetException.class) + public void testValidateMinAppendSizeBelowLimitThrows() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.setContent(new byte[100]); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + when(storageService.getMinAppendSize()).thenReturn(500L); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test + public void testValidateMinAppendSizeMetPasses() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.setContent(new byte[500]); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + when(storageService.getMinAppendSize()).thenReturn(500L); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + // KISS: verifying method executes cleanly without throwing an exception + assertTrue(true); + } + + @Test + public void testValidateMinAppendSizeExemptWhenUploadCompleteTrue() throws Exception { + MockHttpServletRequest completeReq = new MockHttpServletRequest(); + completeReq.setRequestURI("/files/exists"); + completeReq.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + completeReq.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + completeReq.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + completeReq.setContent(new byte[100]); + + UploadInfo info = new UploadInfo(); + info.setLength(1100L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + when(storageService.getMinAppendSize()).thenReturn(500L); + + validator.validate(HttpMethod.PATCH, completeReq, storageService, "owner"); + // KISS: verifying method executes cleanly without throwing an exception + assertTrue(true); + } + + @Test + public void testValidateMinAppendSizeZeroOrNegativeIgnored() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.setContent(new byte[100]); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + when(storageService.getMinAppendSize()).thenReturn(0L); + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + + when(storageService.getMinAppendSize()).thenReturn(-10L); + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + // KISS: verifying method executes cleanly without throwing an exception + assertTrue(true); + } } diff --git a/src/test/java/me/desair/tus/server/upload/disk/DiskStorageServiceTest.java b/src/test/java/me/desair/tus/server/upload/disk/DiskStorageServiceTest.java index e38237a1..65bfbaed 100644 --- a/src/test/java/me/desair/tus/server/upload/disk/DiskStorageServiceTest.java +++ b/src/test/java/me/desair/tus/server/upload/disk/DiskStorageServiceTest.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -1040,6 +1041,133 @@ public void testMinAppendSizeAndMinSize() { assertThat(diskService.getMinSize(), is(nullValue())); } + @Test(expected = me.desair.tus.server.exception.MaxAppendSizeExceededException.class) + public void testValidateMaxAppendSizeThrowsWhenExceeded() throws Exception { + storageService.setMaxAppendSize(50L); + storageService.validateMaxAppendSize(100L); + } + + @Test + public void testValidateMaxAppendSizePassesWhenWithinLimit() throws Exception { + storageService.setMaxAppendSize(100L); + storageService.validateMaxAppendSize(50L); + storageService.setMaxAppendSize(null); + storageService.validateMaxAppendSize(500L); + // KISS: verifying method executes cleanly without throwing an exception + assertTrue(true); + } + + @Test(expected = me.desair.tus.server.exception.MinAppendSizeNotMetException.class) + public void testValidateMinAppendSizeThrowsWhenNotMet() throws Exception { + storageService.setMinAppendSize(100L); + storageService.validateMinAppendSize(50L); + } + + @Test + public void testValidateMinAppendSizePassesWhenMet() throws Exception { + storageService.setMinAppendSize(100L); + storageService.validateMinAppendSize(150L); + storageService.setMinAppendSize(null); + storageService.validateMinAppendSize(10L); + // KISS: verifying method executes cleanly without throwing an exception + assertTrue(true); + } + + @Test(expected = me.desair.tus.server.exception.MaxAppendSizeExceededException.class) + public void testAppendExceedsMaxAppendSizeThrowsAndPreservesOffset() throws Exception { + UploadInfo info = new UploadInfo(); + info.setLength(1000L); + info = storageService.create(info, null); + + // Initial valid append of 10 bytes + storageService.append( + info, new ByteArrayInputStream("0123456789".getBytes(StandardCharsets.UTF_8))); + assertEquals(Long.valueOf(10L), info.getOffset()); + + storageService.setMaxAppendSize(5L); + try { + storageService.append( + info, new ByteArrayInputStream("abcdefghij".getBytes(StandardCharsets.UTF_8))); + } finally { + UploadInfo reloaded = storageService.getUploadInfo(info.getId()); + assertNotNull(reloaded); + assertEquals(Long.valueOf(10L), reloaded.getOffset()); + assertEquals(10L, Files.size(getUploadDataPath(info.getId()))); + } + } + + @Test(expected = me.desair.tus.server.exception.MinAppendSizeNotMetException.class) + public void testAppendBelowMinAppendSizeThrowsAndPreservesOffset() throws Exception { + UploadInfo info = new UploadInfo(); + info.setLength(1000L); + info = storageService.create(info, null); + + storageService.setMinAppendSize(50L); + try { + storageService.append( + info, new ByteArrayInputStream("0123456789".getBytes(StandardCharsets.UTF_8))); + } finally { + UploadInfo reloaded = storageService.getUploadInfo(info.getId()); + assertNotNull(reloaded); + assertEquals(Long.valueOf(0L), reloaded.getOffset()); + assertEquals(0L, Files.size(getUploadDataPath(info.getId()))); + } + } + + @Test + public void testAppendCompletingUploadBypassesMinAppendSize() throws Exception { + UploadInfo info = new UploadInfo(); + info.setLength(10L); + info = storageService.create(info, null); + + storageService.setMinAppendSize(50L); + UploadInfo completed = + storageService.append( + info, new ByteArrayInputStream("0123456789".getBytes(StandardCharsets.UTF_8))); + assertNotNull(completed); + assertEquals(Long.valueOf(10L), completed.getOffset()); + assertFalse(completed.isUploadInProgress()); + } + + @Test + public void testAppendWithinMaxAppendSizeSucceeds() throws Exception { + UploadInfo info = new UploadInfo(); + info.setLength(1000L); + info = storageService.create(info, null); + + storageService.setMaxAppendSize(50L); + UploadInfo updated = + storageService.append( + info, new ByteArrayInputStream("0123456789".getBytes(StandardCharsets.UTF_8))); + assertNotNull(updated); + assertEquals(Long.valueOf(10L), updated.getOffset()); + } + + @Test(expected = me.desair.tus.server.exception.MinAppendSizeNotMetException.class) + public void testAppendUnknownLengthBelowMinAppendSizeThrows() throws Exception { + UploadInfo info = new UploadInfo(); + info.setLength(null); + info = storageService.create(info, null); + + storageService.setMinAppendSize(50L); + storageService.append( + info, new ByteArrayInputStream("0123456789".getBytes(StandardCharsets.UTF_8))); + } + + @Test + public void testAppendUnknownLengthMeetsMinAppendSizeSucceeds() throws Exception { + UploadInfo info = new UploadInfo(); + info.setLength(null); + info = storageService.create(info, null); + + storageService.setMinAppendSize(10L); + UploadInfo updated = + storageService.append( + info, new ByteArrayInputStream("0123456789".getBytes(StandardCharsets.UTF_8))); + assertNotNull(updated); + assertEquals(Long.valueOf(10L), updated.getOffset()); + } + @Test public void testUploadStorageServiceDefaultMethods() { me.desair.tus.server.upload.UploadStorageService anonymousService = diff --git a/src/test/java/me/desair/tus/server/util/TusServletRequestTest.java b/src/test/java/me/desair/tus/server/util/TusServletRequestTest.java index 4ac1f621..cb3f80be 100644 --- a/src/test/java/me/desair/tus/server/util/TusServletRequestTest.java +++ b/src/test/java/me/desair/tus/server/util/TusServletRequestTest.java @@ -244,4 +244,77 @@ public void getHeaderNotFound() { when(servletRequest.getHeader("X-My-Header")).thenReturn(null); assertNull(request.getHeader("X-My-Header")); } + + @Test + public void testHasContentWithPositiveContentLength() throws Exception { + when(servletRequest.getContentLengthLong()).thenReturn(10L); + assertThat(request.hasContent(), is(true)); + } + + @Test + public void testHasContentWithZeroContentLength() throws Exception { + when(servletRequest.getContentLengthLong()).thenReturn(0L); + assertThat(request.hasContent(), is(false)); + } + + @Test + public void testHasContentChunkedWithData() throws Exception { + when(servletRequest.getContentLengthLong()).thenReturn(-1L); + byte[] data = "payload bytes".getBytes(StandardCharsets.UTF_8); + when(servletRequest.getInputStream()) + .thenReturn(new MockServletInputStream(new ByteArrayInputStream(data))); + + // hasContent should peek first byte and unread it + assertThat(request.hasContent(), is(true)); + + // Verify subsequent read of stream receives all bytes including the peeked byte + InputStream is = request.getContentInputStream(); + byte[] readBytes = IOUtils.toByteArray(is); + assertThat(readBytes, is(data)); + } + + @Test + public void testHasContentChunkedEmpty() throws Exception { + when(servletRequest.getContentLengthLong()).thenReturn(-1L); + byte[] emptyData = new byte[0]; + when(servletRequest.getInputStream()) + .thenReturn(new MockServletInputStream(new ByteArrayInputStream(emptyData))); + + assertThat(request.hasContent(), is(false)); + } + + @Test + public void testHasContentNullStream() throws Exception { + when(servletRequest.getContentLengthLong()).thenReturn(-1L); + when(servletRequest.getInputStream()).thenReturn(null); + + assertThat(request.hasContent(), is(false)); + } + + @Test + public void testHasContentCached() throws Exception { + when(servletRequest.getContentLengthLong()).thenReturn(-1L); + byte[] data = "cached data".getBytes(StandardCharsets.UTF_8); + when(servletRequest.getInputStream()) + .thenReturn(new MockServletInputStream(new ByteArrayInputStream(data))); + + assertThat(request.hasContent(), is(true)); + // Second invocation returns cached boolean + assertThat(request.hasContent(), is(true)); + + InputStream is = request.getContentInputStream(); + byte[] readBytes = IOUtils.toByteArray(is); + assertThat(readBytes, is(data)); + } + + @Test + public void testHasContentWithInvalidContentLengthHeaderFallsBackToStream() throws Exception { + when(servletRequest.getContentLengthLong()).thenReturn(-1L); + when(servletRequest.getHeader(HttpHeader.CONTENT_LENGTH)).thenReturn("non-numeric"); + byte[] data = "stream data".getBytes(StandardCharsets.UTF_8); + when(servletRequest.getInputStream()) + .thenReturn(new MockServletInputStream(new ByteArrayInputStream(data))); + + assertThat(request.hasContent(), is(true)); + } }