From 94b11f46d977ab68f0041a1f49047cb443d68356 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 4 Jul 2026 13:35:23 +0200 Subject: [PATCH 1/9] feat: Implement IETF Resumable Uploads for HTTP (RUFH) draft-11 protocol --- .github/workflows/spec-watch.yml | 77 ++++ .gitignore | 3 + AGENTS.md | 71 +++- CHANGELOG.md | 10 + README.md | 50 ++- docs/CONFORMITY_TESTING.md | 67 ++++ docs/MIGRATION.md | 131 +++++++ pom.xml | 2 +- scripts/release.py | 29 ++ .../java/me/desair/tus/server/HttpHeader.java | 27 ++ .../me/desair/tus/server/ProtocolVersion.java | 28 ++ .../me/desair/tus/server/RequestHandler.java | 47 +++ .../me/desair/tus/server/TusExtension.java | 89 +++++ .../tus/server/TusFileUploadService.java | 129 +++++- .../checksum/ChecksumPatchRequestHandler.java | 2 +- .../server/core/CorePatchRequestHandler.java | 15 +- .../tus/server/rufh/HttpProblemDetails.java | 279 +++++++++++++ .../server/rufh/InterimResponseStrategy.java | 19 + .../rufh/NoOpInterimResponseStrategy.java | 15 + .../rufh/ResumableUploadsForHttpProtocol.java | 93 +++++ .../RufhAppendPatchRequestHandler.java | 101 +++++ .../RufhCreationPostRequestHandler.java | 164 ++++++++ .../handler/RufhDeleteRequestHandler.java | 57 +++ .../server/rufh/handler/RufhErrorHandler.java | 89 +++++ .../rufh/handler/RufhHeadRequestHandler.java | 89 +++++ .../handler/RufhOptionsRequestHandler.java | 79 ++++ .../rufh/validation/RufhAppendValidator.java | 119 ++++++ .../validation/RufhCreationValidator.java | 78 ++++ .../validation/RufhHeadHeaderValidator.java | 40 ++ .../validation/RufhSafePathValidator.java | 37 ++ .../validation/RufhUploadExistsValidator.java | 39 ++ .../server/upload/UploadStorageService.java | 20 + .../upload/disk/DiskStorageService.java | 15 + .../tus/server/util/AbstractTusExtension.java | 71 +++- .../tus/server/util/StructuredHeaderUtil.java | 169 ++++++++ .../me/desair/tus/server/CoverageGapTest.java | 238 +++++++++++ .../tus/server/ITTusFileUploadService.java | 4 +- .../tus/server/TusFileUploadServiceTest.java | 186 +++++++++ .../core/CorePatchRequestHandlerTest.java | 2 +- .../server/rufh/HttpProblemDetailsTest.java | 114 ++++++ .../server/rufh/RufhProtocolAppendTest.java | 370 ++++++++++++++++++ .../rufh/RufhProtocolCancellationTest.java | 70 ++++ .../server/rufh/RufhProtocolCreationTest.java | 238 +++++++++++ .../tus/server/rufh/RufhProtocolHeadTest.java | 146 +++++++ .../RufhAppendPatchRequestHandlerTest.java | 94 +++++ .../RufhCreationPostRequestHandlerTest.java | 120 ++++++ .../handler/RufhDeleteRequestHandlerTest.java | 70 ++++ .../rufh/handler/RufhErrorHandlerTest.java | 80 ++++ .../handler/RufhHeadRequestHandlerTest.java | 105 +++++ .../RufhOptionsRequestHandlerTest.java | 70 ++++ .../rufh/security/RufhSecurityTest.java | 128 ++++++ .../validation/RufhAppendValidatorTest.java | 106 +++++ .../validation/RufhCreationValidatorTest.java | 72 ++++ .../RufhHeadHeaderValidatorTest.java | 54 +++ .../validation/RufhSafePathValidatorTest.java | 54 +++ .../RufhUploadExistsValidatorTest.java | 58 +++ .../upload/disk/DiskStorageServiceTest.java | 12 + .../server/util/StructuredHeaderUtilTest.java | 61 +++ 58 files changed, 4764 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/spec-watch.yml create mode 100644 docs/CONFORMITY_TESTING.md create mode 100644 docs/MIGRATION.md create mode 100644 src/main/java/me/desair/tus/server/ProtocolVersion.java create mode 100644 src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java create mode 100644 src/main/java/me/desair/tus/server/rufh/InterimResponseStrategy.java create mode 100644 src/main/java/me/desair/tus/server/rufh/NoOpInterimResponseStrategy.java create mode 100644 src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java create mode 100644 src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java create mode 100644 src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java create mode 100644 src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java create mode 100644 src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java create mode 100644 src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java create mode 100644 src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java create mode 100644 src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java create mode 100644 src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java create mode 100644 src/main/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidator.java create mode 100644 src/main/java/me/desair/tus/server/rufh/validation/RufhSafePathValidator.java create mode 100644 src/main/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidator.java create mode 100644 src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java create mode 100644 src/test/java/me/desair/tus/server/CoverageGapTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/RufhProtocolHeadTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/security/RufhSecurityTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidatorTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidatorTest.java create mode 100644 src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java diff --git a/.github/workflows/spec-watch.yml b/.github/workflows/spec-watch.yml new file mode 100644 index 00000000..47106156 --- /dev/null +++ b/.github/workflows/spec-watch.yml @@ -0,0 +1,77 @@ +name: Monitor IETF Resumable Uploads Spec Updates + +on: + schedule: + - cron: '0 0 * * *' # Run daily at midnight UTC + workflow_dispatch: # Allow manual trigger + +jobs: + check-spec-update: + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check IETF Datatracker API for Spec Revision + id: spec_check + run: | + # Fetch spec document metadata from IETF Datatracker API + API_RESPONSE=$(curl -s https://datatracker.ietf.org/api/v1/doc/document/draft-ietf-httpbis-resumable-upload/) + LATEST_REV=$(echo "$API_RESPONSE" | jq -r '.rev // empty') + + if [ -z "$LATEST_REV" ]; then + echo "Failed to fetch latest revision from IETF Datatracker" + exit 1 + fi + + echo "Latest IETF Draft Revision: $LATEST_REV" + echo "latest_rev=$LATEST_REV" >> $GITHUB_OUTPUT + + # Currently implemented baseline revision in tus-java-server: 11 + BASELINE_REV="11" + echo "baseline_rev=$BASELINE_REV" >> $GITHUB_OUTPUT + + if [ "$LATEST_REV" != "$BASELINE_REV" ]; then + echo "spec_updated=true" >> $GITHUB_OUTPUT + else + echo "spec_updated=false" >> $GITHUB_OUTPUT + fi + + - name: Create GitHub Issue if Spec Updated + if: steps.spec_check.outputs.spec_updated == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LATEST_REV: ${{ steps.spec_check.outputs.latest_rev }} + BASELINE_REV: ${{ steps.spec_check.outputs.baseline_rev }} + run: | + TITLE="IETF Resumable Uploads Spec Updated: draft-ietf-httpbis-resumable-upload-$LATEST_REV" + + # Check if an issue for this revision already exists + EXISTING_ISSUE=$(gh issue list --search "$TITLE" --json number --jq '.[0].number') + + if [ -n "$EXISTING_ISSUE" ]; then + echo "An issue (#$EXISTING_ISSUE) for revision $LATEST_REV already exists." + exit 0 + fi + + BODY=$(cat <`) or value objects and format them safely with proper JSON string escaping (handling quotes, backslashes, and control characters). + +### 7. File Deduplication and Read/Write Safety The deduplication mechanism links duplicate uploads (child) to the original upload (parent) using the `duplicatesUploadId` field in `UploadInfo`. - **Read Operations**: Methods that read data (e.g., `getUploadedBytes`, `copyUploadTo` in `DiskStorageService`) should dynamically resolve `duplicatesUploadId` to the parent upload ID if it is set. - **Write/Modify Operations**: Methods that write or truncate data (e.g., `append`, `removeLastNumberOfBytes` in `DiskStorageService`) **must not** resolve `duplicatesUploadId` recursively. They must only operate on the target upload's own physical files to guarantee parent files are never modified or truncated when handling child upload errors. -### 4. Checksum Index Storage & Self-Cleaning +### 8. Checksum Index Storage & Self-Cleaning Completed parent uploads are indexed by checksum under the `/checksums//` file path containing the target `UploadId`. - Index lookup includes a self-cleaning check: if the index points to an upload that is null or whose data file is missing (e.g., due to expiration), the index file is deleted on the fly, keeping the file system clean without needing a separate index sweeper. - Child uploads (duplicates) are never indexed. - On parent termination, the parent's index entry is explicitly deleted. -### 5. No Thread-Local Contexts +### 9. No Thread-Local Contexts - Do not use `ThreadLocal` variables or thread-local request context to pass state between components. Always pass parameters explicitly or use request wrapping. -### 6. Unit Test Coverage +### 10. Unit Test Coverage & Verbatim Spec Quotes - Unit test coverage must remain high. All new code (including background watchdog threads, helper methods, stream wrappers, and retry logic) must be thoroughly unit tested. - Do not use reflection to test private helper methods. Always test code through public API boundaries instead of bypassing encapsulation. +- Compliance unit tests in `me.desair.tus.server.rufh` MUST contain verbatim specification quotes in method Javadocs based on the official specification. - After finalizing any implementation, you MUST check the unit test coverage on new/modified lines by running: ```bash mvn verify -Pcheck-coverage -Djacoco.compare.branch=master ``` If any added or modified lines are reported as uncovered (❌) or partially covered (⚠️), you must add extra unit tests to cover them before submitting. -### 7. No Code Duplication & Reusability -- Avoid code duplication wherever possible. Consolidate repetitive logic (such as path generation, header parsing, and lock acquisition/release) into reusable helper functions or utility classes. Keep file path resolutions consolidated. +### 11. Mandatory Javadocs & Code Formatting +- Always write thorough Javadoc comments for all new and modified public/protected classes, interfaces, and methods. +- Always remove unused imports across all modified and newly created Java source files. +- Run code formatting before committing: + ```bash + mvn -P codestyle com.spotify.fmt:fmt-maven-plugin:format + ``` -### 8. Documentation & Comments -- All new classes, interfaces, and non-obvious code blocks (e.g., watchdog lifecycle, stream wrapping logic, thread-safety mechanisms) must have detailed comments describing their purpose, behavior, and design decisions. +### 12. String Comparisons & Avoiding Deprecated StringUtils +- Do not use deprecated `StringUtils` comparison methods such as `StringUtils.equals(...)` or `StringUtils.equalsIgnoreCase(...)`. +- Always use `org.apache.commons.lang3.Strings.CS` for case-sensitive operations (e.g., `Strings.CS.equals(...)`, `Strings.CS.startsWith(...)`) and `org.apache.commons.lang3.Strings.CI` for case-insensitive operations (e.g., `Strings.CI.equals(...)`, `Strings.CI.startsWith(...)`). + +## IETF Resumable Uploads for HTTP (RUFH) Spec Maintenance & Update Playbook + +### 1. Spec Diff Review +When a new draft revision of the IETF Resumable Uploads specification (`draft-ietf-httpbis-resumable-upload`: https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/) is published: +- Compare the new draft against the current baseline (draft-11) using the official IETF Author Tools diff: + `https://author-tools.ietf.org/diff?doc_1=draft-ietf-httpbis-resumable-upload-11&doc_2=draft-ietf-httpbis-resumable-upload-` +- Identify any changed header names, structured field syntax changes, response status codes, or problem details schemas. + +### 2. Spec-Driven Compliance Test Maintenance +Compliance unit tests located in `src/test/java/me/desair/tus/server/rufh/` (`RufhProtocolCreationTest`, `RufhProtocolAppendTest`, `RufhProtocolHeadTest`, `RufhProtocolCancellationTest`, `HttpProblemDetailsTest`) contain verbatim quotes from the specification in their method Javadocs. +- **Workflow**: + 1. Update the verbatim spec quotes in test method Javadocs to reflect the new draft revision text. + 2. Update test assertions and expected header/status formats. + 3. Run `mvn test` to pinpoint which server components need code updates. + +### 3. Protocol Update Skill & Execution Procedure +When updating the IETF protocol implementation for a new draft revision, follow this step-by-step procedure: +1. **Branching**: Ensure you are on a feature branch (e.g. `feature/ietf-spec-draft-`). +2. **Protocol Headers**: Update header definitions in `HttpHeader.java` if structured field keys or parameter names changed. +3. **Structured Header Utility**: Update `StructuredHeaderUtil.java` if RFC 9651 structured field parsing rules or data types changed. +4. **Problem Details**: Update `HttpProblemDetails.java` if RFC 7807 problem json type URIs or field keys changed. +5. **Protocol Logic**: Update `ResumableUploadsForHttpProtocol.java` validation and processing logic. +6. **Coverage Verification**: Verify code coverage and unit tests pass: + ```bash + mvn verify -Pcheck-coverage -Djacoco.compare.branch=master + ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1a8197..bc0435db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [2.0.0] + +### Added +- **IETF Resumable Uploads for HTTP (RUFH) Protocol Compliance**: Implemented full support for the official IETF Resumable Uploads for HTTP specification (`draft-ietf-httpbis-resumable-upload`). +- **Dual Protocol Auto-Detection**: Added transparent protocol routing in `TusFileUploadService` supporting both legacy `TUS_1_0_0` (`Tus-Resumable: 1.0.0`) and `RUFH` (`ProtocolVersion.RUFH`) clients concurrently on the same endpoint. +- **RFC 9651 Structured Header Fields**: Implemented RFC 9651 parsing and serialization for `Upload-Offset`, `Upload-Complete`, `Upload-Length`, and `Upload-Limit` dictionary headers. +- **RFC 7807 Problem Details JSON**: Added support for standard `application/problem+json` error responses (`mismatching-upload-offset`, `completed-upload`, `inconsistent-upload-length`). +- **Dedicated Compliance & Security Test Suites**: Added comprehensive, spec-quoted unit tests under package `me.desair.tus.server.ietf` and security tests under `me.desair.tus.server.ietf.security` verifying Path Traversal protection, DoS limits, CRLF sanitization, and lock safety. +- **User Migration Documentation**: Added `docs/MIGRATION.md` detailing steps for transitioning from Tus 1.0.0 to IETF RUFH. + ## [1.0.0-3.2] ### Added diff --git a/README.md b/README.md index 889d6a5a..a9f0d354 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ [![Build and Tests](https://github.com/tomdesair/tus-java-server/actions/workflows/build.yml/badge.svg)](https://github.com/tomdesair/tus-java-server/actions?query=branch%3Amaster+) [![Coverage Status](https://coveralls.io/repos/github/tomdesair/tus-java-server/badge.svg?branch=master)](https://coveralls.io/github/tomdesair/tus-java-server?branch=master) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=bugs)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) [![Duplicated Lines](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) # tus-java-server -This library can be used to enable resumable (and potentially asynchronous) file uploads in any Java web application. This allows the users of your application to upload large files over slow and unreliable internet connections. The ability to pause or resume a file upload (after a connection loss or reset) is achieved by implementing the open file upload protocol tus (https://tus.io/). This library implements the server-side of the tus v1.0.0 protocol with [all optional extensions](#tus-protocol-extensions). +This library can be used to enable resumable (and potentially asynchronous) file uploads in any Java web application. This allows the users of your application to upload large files over slow and unreliable internet connections. The ability to pause or resume a file upload (after a connection loss or reset) is achieved by implementing the open file upload protocol tus (https://tus.io/). This library implements the server-side of the tus v1.0.0 protocol as well as the official IETF Resumable Uploads for HTTP specification ([draft-ietf-httpbis-resumable-upload](https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/)), offering dual protocol version support. -The Javadoc of this library can be found at https://tus.desair.me/. As of version 1.0.0-3.0, this library requires Java 17+. For older Java versions, please use one of the `1.0.0-2.x` releases. +The Javadoc of this library can be found at https://tus.desair.me/. As of version 2.0.0, this library requires Java 17+. ## Quick Start and Examples The tus-java-server library only depends on Jakarta Servlet API 6.0 and some Apache Commons utility libraries. This @@ -14,7 +14,7 @@ You can add the latest stable version of this library to your application using me.desair.tus tus-java-server - 1.0.0-3.0-SNAPSHOT + 2.0.0-SNAPSHOT The main entry point of the library is the `me.desair.tus.server.TusFileUploadService.process(jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse)` method. You can call this method inside a `jakarta.servlet.http.HttpServlet`, a `jakarta.servlet.Filter` or any REST API controller of a framework that gives you access to `HttpServletRequest` and `HttpServletResponse` objects. In the following list, you can find some example implementations: @@ -24,6 +24,38 @@ The main entry point of the library is the `me.desair.tus.server.TusFileUploadSe * [Resumable and asynchronous file upload in Spring Boot REST API with Uppy JavaScript client.](https://github.com/tomdesair/tus-java-server-spring-demo) * (more examples to come!) +## Protocol Version Support (Tus 1.0.0 & IETF Resumable Uploads) + +> [!WARNING] +> **Experimental Feature Disclaimer**: The IETF Resumable Uploads for HTTP (RUFH) specification (`draft-ietf-httpbis-resumable-upload`) is currently an active IETF draft. While this library implements draft-11 compliance, the RUFH protocol support should be considered **experimental** until the specification is published as an official RFC standard. + +`tus-java-server` supports both protocol specifications seamlessly: +1. **Tus 1.0.0**: The widely-adopted [tus protocol standard](https://tus.io/). +2. **IETF Resumable Uploads for HTTP**: The official IETF standardization draft ([draft-ietf-httpbis-resumable-upload](https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/)). + +### Configuring Protocol Version +You can configure protocol support via `withSupportedProtocolVersions(ProtocolVersion)`: + +* `ProtocolVersion.AUTO` (Default): Automatically detects protocol version per HTTP request based on request headers (`Tus-Resumable` header triggers Tus 1.0.0; `Upload-Complete` header or `application/partial-upload` content type triggers IETF RUFH). +* `ProtocolVersion.TUS_1_0_0`: Enforces Tus 1.0.0 handling exclusively. +* `ProtocolVersion.RUFH`: Enforces IETF Resumable Uploads for HTTP (RUFH) handling exclusively. + +### Protocol Comparison & Available Features + +| Feature / Capability | Tus 1.0.0 | IETF Resumable Uploads | +|---|---|---| +| **Auto-Detection Signal** | `Tus-Resumable: 1.0.0` header | `Upload-Complete` header or `Content-Type: application/partial-upload` | +| **Creation** | `POST` with `Upload-Length` / `Upload-Defer-Length` | `POST` with `Upload-Complete: ?0` / `?1` (or `application/partial-upload`) | +| **Append Chunks** | `PATCH` with `Upload-Offset` | `PATCH` with `Upload-Offset` & `Content-Type: application/partial-upload` | +| **Upload Status Query** | `HEAD` returns `Upload-Offset` & `Upload-Length` | `HEAD` returns `Upload-Offset` & `Upload-Complete` | +| **Offset Mismatch Error** | HTTP 409 Conflict | HTTP 409 Conflict with RFC 7807 `application/problem+json` details | +| **104 Interim Responses** | N/A | Supported (`InterimResponseStrategy`) | +| **Upload Cancellation** | `DELETE` with `Tus-Resumable: 1.0.0` | `DELETE` with `Upload-Complete: ?0` | +| **Checksum Validation** | Supported (`Checksum` extension) | Supported (`Checksum` extension) | +| **Expiration Handling** | Supported (`Expiration` extension) | Supported (`Expiration` extension) | +| **Concatenation** | Supported (`Concatenation` extension) | Supported (`Concatenation` extension) | +| **Download Extension** | Supported (`Download` extension) | Supported (`Download` extension) | + ## Tus Protocol Extensions Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-protocol), the library has all optional tus protocol extensions enabled by default. This means that the `Tus-Extension` header has value `creation,creation-defer-length,checksum,checksum-trailer,termination,expiration,concatenation,concatenation-unfinished`. Optionally you can also enable an unofficial `download` extension (see [configuration section](#usage-and-configuration)). @@ -43,6 +75,7 @@ Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core- The first step is to create a `TusFileUploadService` object using its constructor. You can make this object available as a (Spring bean) singleton or create a new instance for each request. After creating the object, you can configure it using the following methods: * `withUploadUri(String)`: Set the relative URL under which the main tus upload endpoint will be made available, for example `/files/upload`. Optionally, this URI may contain regex parameters in order to support endpoints that contain URL parameters, for example `/users/[0-9]+/files/upload`. +* `withSupportedProtocolVersions(ProtocolVersion)`: Configure supported protocol versions (`ProtocolVersion.AUTO` for automatic header-based detection, `ProtocolVersion.TUS_1_0_0` for Tus 1.0.0 only, or `ProtocolVersion.IETF` for IETF Resumable Uploads only). * `withMaxUploadSize(Long)`: Specify the maximum number of bytes that can be uploaded per upload. If you don't call this method, the maximum number of bytes is `Long.MAX_VALUE`. * `withStoragePath(String)`: If you're using the default file system-based storage service, you can use this method to specify the path where to store the uploaded bytes and upload information. * `withChunkedTransferDecoding`: You can enable or disable the decoding of chunked HTTP requests by this library. Enable this feature in case the web container in which this service is running does not decode chunked transfers itself. By default, chunked decoding via this library is disabled (as modern frameworks tend to already do this for you). @@ -74,10 +107,17 @@ After having processed the uploaded bytes on the server backend (e.g. copy them Next to removing uploads after they have been completed and processed by the backend, it is also recommended to schedule a regular maintenance task to clean up any expired uploads or locks. Cleaning up expired uploads and locks can be achieved using the `me.desair.tus.server.TusFileUploadService.cleanup()` method. ## Compatible Client Implementations -This tus protocol implementation has been [tested](https://github.com/tomdesair/tus-java-server-spring-demo) with the [Uppy file upload client](https://uppy.io/). This repository also contains [many automated integration tests](https://github.com/tomdesair/tus-java-server/blob/master/src/test/java/me/desair/tus/server/ITTusFileUploadService.java) that validate the tus protocol server implementation using plain HTTP requests. So in theory this means we're compatible with any tus 1.0.0 compliant client. +This server implementation has been tested with: +- **Tus 1.0.0 Clients**: Tested with [Uppy](https://uppy.io/) and `tus-js-client`. +- **IETF Resumable Uploads Clients**: + - `tus-js-client`: Features experimental support for the IETF draft (configured via `protocol: 'ietf-draft-03'` / `'ietf-draft-05'`). + - Native Apple Platforms (`URLSession`): iOS 17+ and macOS 14+ natively support the IETF HTTP Resumable Uploads specification. + - Custom HTTP Clients: Standard HTTP clients sending `Upload-Complete` / `application/partial-upload` structured headers. + +This repository also contains comprehensive automated integration test suites (`ITTusFileUploadService`, `IetfProtocolCreationTest`, `IetfProtocolAppendTest`, `IetfProtocolHeadTest`, `IetfProtocolCancellationTest`) validating both protocol specifications. ## Versioning -This artifact is versioned as `A.B.C-X.Y` where `A.B.C` is the version of the implemented tus protocol (currently 1.0.0) and `X.Y` is the version of this library. +This artifact follows `MAJOR.MINOR.PATCH` semantic versioning. Version `2.0.0` introduces major dual-protocol support for both Tus 1.0.0 and the IETF Resumable Uploads for HTTP specification (`draft-ietf-httpbis-resumable-upload`). ## Contributing This library comes without any warranty and is released under a [MIT license](https://github.com/tomdesair/tus-java-server/blob/master/LICENSE). If you encounter any bugs or if you have an idea for a useful improvement you are welcome to [open a new issue](https://github.com/tomdesair/tus-java-server/issues) or to [create a pull request](https://github.com/tomdesair/tus-java-server/pulls) with the proposed implementation. Please note that any contributed code needs to be accompanied by automated unit and/or integration tests and comply with the [defined code-style](#code-style). diff --git a/docs/CONFORMITY_TESTING.md b/docs/CONFORMITY_TESTING.md new file mode 100644 index 00000000..617a5653 --- /dev/null +++ b/docs/CONFORMITY_TESTING.md @@ -0,0 +1,67 @@ +# Conformity Testing Guide (IETF Resumable Uploads for HTTP) + +This guide describes how to manually execute the RUFH conformity tests against a locally running instance of the Spring Boot demo server using the community conformity testing suite. + +--- + +## 1. Build and Install the Server Library + +First, compile and install the core `tus-java-server` library to your local Maven repository: + +```bash +# In the root of the tus-java-server repository +mvn clean install +``` + +--- + +## 2. Start the Demo Server + +1. Update the dependency version in the demo project if necessary. In `tus-java-server-spring-demo` project's `spring-boot-rest/pom.xml`, verify it points to the locally built snapshot version: + ```xml + + me.desair.tus + tus-java-server + 2.0.0-SNAPSHOT + + ``` + +2. Build and start the Spring Boot REST demo server: + ```bash + cd ../tus-java-server-spring-demo + mvn clean package + java -jar spring-boot-rest/target/spring-boot-rest-0.0.1-SNAPSHOT.jar + ``` + + The server will start on port `8080` with the upload endpoint exposed at: + `http://localhost:8080/test/api/upload` + +--- + +## 3. Clone and Run the Conformity Tester + +The conformity tests are written in Python using `pytest` and are maintained by the community under the `ietf-hackathon` repository. + +1. Clone the repository and navigate to the tests directory: + ```bash + git clone https://github.com/tus/ietf-hackathon.git + cd ietf-hackathon/tests + ``` + +2. Set up a Python virtual environment and activate it: + ```bash + python3 -m venv venv + source venv/bin/activate + ``` + +3. Install required Python packages: + ```bash + pip install -r requirements.txt + ``` + +4. Run the conformity tests pointing to your locally running Spring Boot endpoint: + ```bash + pytest --url http://localhost:8080/test/api/upload + ``` + + All tests should pass, certifying that the server implementation conforms to the IETF Resumable Uploads for HTTP (RUFH) specification. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 00000000..c97e8122 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,131 @@ +# Migration Guide: Tus 1.0.0 to IETF Resumable Uploads + +This guide provides step-by-step instructions for upgrading `tus-java-server` applications and clients to support the official **IETF Resumable Uploads for HTTP specification** ([draft-ietf-httpbis-resumable-upload](https://www.ietf.org/archive/id/draft-ietf-httpbis-resumable-upload-11.html)). + +--- + +## 1. Overview & Compatibility + +`tus-java-server` version 2.0.0 introduces full support for the IETF Resumable Uploads standard while maintaining **100% backward compatibility** with legacy Tus 1.0.0 clients. + +- **Zero Breaking Changes for Existing Tus 1.0.0 Clients**: Legacy clients sending `Tus-Resumable: 1.0.0` header fields will continue to operate without modification. +- **Dual-Protocol Support**: By default, `TusFileUploadService` automatically detects the protocol version (`ProtocolVersion.AUTO`) used by incoming requests and responds appropriately. + +--- + +## 2. Server Configuration + +### Enabling Protocol Options +You can configure protocol support via the `withSupportedProtocolVersions(...)` method on `TusFileUploadService`: + +```java +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.TusFileUploadService; + +TusFileUploadService tusService = new TusFileUploadService() + .withUploadUri("/files") + // Enabled by default: AUTO mode detects TUS_1_0_0 vs IETF per request + .withSupportedProtocolVersions(ProtocolVersion.AUTO); +``` + +To restrict the server to only IETF Resumable Uploads or legacy Tus 1.0.0: +```java +// Force server to only serve IETF Resumable Uploads for HTTP (RUFH) +tusService.withSupportedProtocolVersions(ProtocolVersion.RUFH); + +// Force server to only serve legacy Tus 1.0.0 +tusService.withSupportedProtocolVersions(ProtocolVersion.TUS_1_0_0); +``` + +--- + +## 3. Protocol Header Mapping + +When updating clients or proxies to use IETF Resumable Uploads, note the following header changes: + +| Concept / Header | Tus 1.0.0 | IETF Resumable Uploads | +| :--- | :--- | :--- | +| **Version Header** | `Tus-Resumable: 1.0.0` | *Omitted* (protocol autodetected via `Upload-Complete`) | +| **Upload Offset** | `Upload-Offset: 1234` | `Upload-Offset: 1234` (RFC 9651 Integer) | +| **Upload Length** | `Upload-Length: 5000` | `Upload-Length: 5000` (RFC 9651 Integer) | +| **Completeness** | Implicitly checked by size | `Upload-Complete: ?1` (true) or `Upload-Complete: ?0` (false) | +| **Partial Upload Media Type** | `Content-Type: application/offset+octet-stream` | `Content-Type: application/partial-upload` | +| **Upload Limits** | `Tus-Max-Size: 104857600` | `Upload-Limit: max-size=104857600, max-append-size=5242880` | +| **Capabilities Discovery** | `OPTIONS` returns `Tus-Version`, `Tus-Extension` | `OPTIONS` returns `Accept-Patch: application/partial-upload` and `Upload-Limit` | +| **Error Format** | Plain text | `application/problem+json` (RFC 7807) | + +--- + +## 4. Key Protocol Changes for Client Integrations + +### 4.1 Upload Creation (`POST`, `PUT`, `PATCH`) +Clients can initiate uploads via `POST`, `PUT`, or `PATCH`. The request must contain the `Upload-Complete` header: + +- **Optimistic Creation (Sending Data Immediately)**: + ```http + POST /files HTTP/1.1 + Host: example.com + Upload-Length: 123456 + Upload-Complete: ?1 + Content-Length: 123456 + + [binary content] + ``` + +- **Chunked / Multi-Part Creation**: + ```http + POST /files HTTP/1.1 + Host: example.com + Upload-Length: 123456 + Upload-Complete: ?0 + + ``` + *Response*: `201 Created` with `Location: /files/b530ce8ff` and `Upload-Limit: max-size=...`. + +### 4.2 Appending Data (`PATCH`) +Clients send chunk appends using `Content-Type: application/partial-upload`: + +```http +PATCH /files/b530ce8ff HTTP/1.1 +Host: example.com +Upload-Offset: 0 +Upload-Complete: ?0 +Content-Type: application/partial-upload +Content-Length: 50000 + +[partial binary content] +``` + +To send the final chunk: +```http +PATCH /files/b530ce8ff HTTP/1.1 +Host: example.com +Upload-Offset: 50000 +Upload-Complete: ?1 +Content-Type: application/partial-upload +Content-Length: 73456 + +[final binary content] +``` + +### 4.3 Error Handling (`application/problem+json`) +IETF Resumable Upload error responses return RFC 7807 problem details JSON: + +- **Offset Mismatch (`409 Conflict`)**: + ```json + { + "type": "https://iana.org/assignments/http-problem-types#mismatching-upload-offset", + "title": "offset from request does not match offset of resource", + "expected-offset": 12500000, + "provided-offset": 25000000 + } + ``` + +--- + +## 5. Reverse Proxies & Load Balancers + +Ensure that reverse proxies (Nginx, HAProxy, AWS ALB, Cloudflare): +1. Forward custom headers: `Upload-Offset`, `Upload-Complete`, `Upload-Length`, `Upload-Limit`, `Upload-Draft`. +2. Do not strip or alter `Content-Type: application/partial-upload`. +3. Support HTTP `PATCH` and `DELETE` requests. diff --git a/pom.xml b/pom.xml index f2137ba1..0403cedf 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ me.desair.tus tus-java-server - 1.0.0-3.3-SNAPSHOT + 2.0.0-SNAPSHOT jar ${project.groupId}:${project.artifactId} diff --git a/scripts/release.py b/scripts/release.py index 2084fb2d..ecf2fd1d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -95,6 +95,26 @@ def check_changelog(release_version): except Exception as e: return False, f"Error reading CHANGELOG.md: {e}" +def update_readme_version(new_version, readme_path="README.md"): + if not os.path.exists(readme_path): + return False, f"{readme_path} not found" + try: + with open(readme_path, "r", encoding="utf-8") as f: + content = f.read() + + # Update Maven dependency version block in README.md + pattern = r"(tus-java-server\s*)[^<]+()" + if re.search(pattern, content): + new_content = re.sub(pattern, r"\g<1>" + new_version + r"\g<2>", content) + if new_content != content: + with open(readme_path, "w", encoding="utf-8") as f: + f.write(new_content) + return True, None + return True, "README.md version already up to date" + return False, "Could not find tus-java-server dependency version block in README.md" + except Exception as e: + return False, f"Error updating {readme_path}: {e}" + def get_changelog_notes(release_version): if not os.path.exists("CHANGELOG.md"): return "" @@ -338,6 +358,15 @@ def main(): sys.exit(1) print(f"\r[\033[92m✅\033[0m] Checking CHANGELOG.md") + # Update version in README.md + print("[ ] Updating README.md version...", end="", flush=True) + readme_updated, readme_msg = update_readme_version(release_version) + if not readme_updated: + print(f"\r[\033[91m❌\033[0m] Updating README.md version") + print(f"Error: {readme_msg}") + sys.exit(1) + print(f"\r[\033[92m✅\033[0m] Updating README.md version ({release_version})") + # Setup environment for subprocesses # We must ensure we do not pass invalid GITHUB_TOKEN to gh CLI or maven if it causes auth problems sub_env = os.environ.copy() diff --git a/src/main/java/me/desair/tus/server/HttpHeader.java b/src/main/java/me/desair/tus/server/HttpHeader.java index 951708bb..75fc8ccc 100644 --- a/src/main/java/me/desair/tus/server/HttpHeader.java +++ b/src/main/java/me/desair/tus/server/HttpHeader.java @@ -101,6 +101,33 @@ public class HttpHeader { */ public static final String X_FORWARDED_FOR = "X-Forwarded-For"; + /** + * The Upload-Complete request and response header field conveys the completeness state in IETF + * Resumable Uploads. Its value is a Structured Header Boolean (?1 or ?0). + */ + public static final String UPLOAD_COMPLETE = "Upload-Complete"; + + /** + * The Upload-Limit response header field indicates limits applicable to the upload resource in + * IETF Resumable Uploads. Its value is a Structured Header Dictionary. + */ + public static final String UPLOAD_LIMIT = "Upload-Limit"; + + /** The Upload-Draft request header field indicates draft parameters in IETF Resumable Uploads. */ + public static final String UPLOAD_DRAFT = "Upload-Draft"; + + /** + * The Accept-Patch response header field advertises accepted patch media types (e.g. + * application/partial-upload). + */ + public static final String ACCEPT_PATCH = "Accept-Patch"; + + /** Media type application/partial-upload used for IETF Resumable Upload append requests. */ + public static final String CONTENT_TYPE_PARTIAL_UPLOAD = "application/partial-upload"; + + /** Media type application/problem+json used for RFC 7807 problem details error responses. */ + public static final String CONTENT_TYPE_PROBLEM_JSON = "application/problem+json"; + private HttpHeader() { // This is an utility class to hold constants } diff --git a/src/main/java/me/desair/tus/server/ProtocolVersion.java b/src/main/java/me/desair/tus/server/ProtocolVersion.java new file mode 100644 index 00000000..017c5f37 --- /dev/null +++ b/src/main/java/me/desair/tus/server/ProtocolVersion.java @@ -0,0 +1,28 @@ +package me.desair.tus.server; + +/** Enumeration of supported file upload protocol versions. */ +public enum ProtocolVersion { + /** + * The Tus v1.0.0 upload protocol specification (https://tus.io/protocols/resumable-upload.html). + */ + TUS_1_0_0("TUS-1.0.0"), + + /** + * The official IETF Resumable Uploads for HTTP (RUFH) specification + * (draft-ietf-httpbis-resumable-upload). + */ + RUFH("RUFH"), + + /** Automatic protocol selection based on incoming request header indicators. */ + AUTO("AUTO"); + + private final String name; + + ProtocolVersion(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/me/desair/tus/server/RequestHandler.java b/src/main/java/me/desair/tus/server/RequestHandler.java index f089788e..86a35114 100644 --- a/src/main/java/me/desair/tus/server/RequestHandler.java +++ b/src/main/java/me/desair/tus/server/RequestHandler.java @@ -2,14 +2,33 @@ import java.io.IOException; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.TusServletRequest; import me.desair.tus.server.util.TusServletResponse; +/** Interface for processing HTTP request logic within protocol extensions. */ public interface RequestHandler { + /** + * Test if this request handler supports the given HTTP method. + * + * @param method The current HTTP method + * @return true if supported, false otherwise + */ boolean supports(HttpMethod method); + /** + * Process the given HTTP request. + * + * @param method The HTTP method of this request + * @param servletRequest The HTTP request + * @param servletResponse The HTTP response + * @param uploadStorageService The current upload storage service + * @param ownerKey Identifier of the owner of this upload + * @throws IOException When an I/O error occurs + * @throws TusException When a protocol error occurs + */ void process( HttpMethod method, TusServletRequest servletRequest, @@ -18,5 +37,33 @@ void process( String ownerKey) throws IOException, TusException; + /** + * Process the given HTTP request with access to the upload locking service. + * + * @param method The HTTP method of this request + * @param servletRequest The HTTP request + * @param servletResponse The HTTP response + * @param uploadStorageService The current upload storage service + * @param uploadLockingService The upload locking service instance + * @param ownerKey Identifier of the owner of this upload + * @throws IOException When an I/O error occurs + * @throws TusException When a protocol error occurs + */ + default void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey) + throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, ownerKey); + } + + /** + * Test if this handler is an error handler invoked during exception processing. + * + * @return true if this handler processes errors, false otherwise + */ boolean isErrorHandler(); } diff --git a/src/main/java/me/desair/tus/server/TusExtension.java b/src/main/java/me/desair/tus/server/TusExtension.java index 15d8102f..24359d45 100644 --- a/src/main/java/me/desair/tus/server/TusExtension.java +++ b/src/main/java/me/desair/tus/server/TusExtension.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.util.Collection; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.TusServletRequest; import me.desair.tus.server.util.TusServletResponse; @@ -18,6 +19,20 @@ public interface TusExtension { */ String getName(); + /** + * Determine if this extension is applicable to the given request method and protocol version. + * + * @param method The HTTP method + * @param version The protocol version (Tus 1.0.0 or IETF RUFH) + * @return true if applicable, false otherwise + */ + default boolean isApplicable(HttpMethod method, ProtocolVersion version) { + if (HttpMethod.OPTIONS.equals(method)) { + return true; + } + return version == ProtocolVersion.TUS_1_0_0; + } + /** * Validate the given request. * @@ -35,6 +50,29 @@ void validate( String ownerKey) throws TusException, IOException; + /** + * Validate the given request with access to the upload locking service and protocol version. + * + * @param method The HTTP method of this request + * @param servletRequest The HTTP request + * @param uploadStorageService The current upload storage service + * @param uploadLockingService The upload locking service instance + * @param ownerKey Identifier of the owner of this upload + * @param version The protocol version of the request + * @throws TusException When the request is invalid + * @throws IOException When unable to read upload information + */ + default void validate( + HttpMethod method, + HttpServletRequest servletRequest, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version) + throws TusException, IOException { + validate(method, servletRequest, uploadStorageService, ownerKey); + } + /** * Process the given request. * @@ -54,6 +92,31 @@ void process( String ownerKey) throws IOException, TusException; + /** + * Process the given request with access to the upload locking service and protocol version. + * + * @param method The HTTP method of this request + * @param servletRequest The HTTP request + * @param servletResponse The HTTP response + * @param uploadStorageService The current upload storage service + * @param uploadLockingService The upload locking service instance + * @param ownerKey Identifier of the owner of this upload + * @param version The protocol version of the request + * @throws TusException When processing the request fails + * @throws IOException When unable to read upload information + */ + default void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version) + throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, ownerKey); + } + /** * If a request is invalid, or when processing the request fails, it might be necessary to react * to this failure. This method allows extensions to react to validation or processing failures. @@ -74,6 +137,32 @@ void handleError( String ownerKey) throws IOException, TusException; + /** + * React to validation or processing failures with access to the upload locking service and + * protocol version. + * + * @param method The HTTP method of this request + * @param servletRequest The HTTP request + * @param servletResponse The HTTP response + * @param uploadStorageService The current upload storage service + * @param uploadLockingService The upload locking service instance + * @param ownerKey Identifier of the owner of this upload + * @param version The protocol version of the request + * @throws TusException When handling the error fails + * @throws IOException When unable to read upload information + */ + default void handleError( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version) + throws IOException, TusException { + handleError(method, servletRequest, servletResponse, uploadStorageService, ownerKey); + } + /** * The minimal list of HTTP methods that this extension needs to function properly. * diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index fefdd9de..7d7c8106 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -17,6 +17,7 @@ import me.desair.tus.server.download.DownloadExtension; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.expiration.ExpirationExtension; +import me.desair.tus.server.rufh.ResumableUploadsForHttpProtocol; import me.desair.tus.server.termination.TerminationExtension; import me.desair.tus.server.upload.UploadIdFactory; import me.desair.tus.server.upload.UploadInfo; @@ -49,6 +50,7 @@ public class TusFileUploadService { private final Set supportedHttpMethods = EnumSet.noneOf(HttpMethod.class); private boolean isThreadLocalCacheEnabled = false; private boolean isChunkedTransferDecodingEnabled = false; + private ProtocolVersion supportedProtocolVersion = ProtocolVersion.AUTO; /** Constructor. */ public TusFileUploadService() { @@ -66,6 +68,30 @@ protected void initFeatures() { addTusExtension(new TerminationExtension()); addTusExtension(new ExpirationExtension()); addTusExtension(new ConcatenationExtension()); + addTusExtension(new ResumableUploadsForHttpProtocol()); + } + + /** + * Configure the supported protocol version(s) for this service. + * + * @param supportedProtocolVersion ProtocolVersion setting (TUS_1_0_0, RUFH, or AUTO) + * @return The current service + */ + public TusFileUploadService withSupportedProtocolVersions( + ProtocolVersion supportedProtocolVersion) { + if (supportedProtocolVersion != null) { + this.supportedProtocolVersion = supportedProtocolVersion; + } + return this; + } + + /** + * Get the configured protocol version setting. + * + * @return Current ProtocolVersion configuration + */ + public ProtocolVersion getSupportedProtocolVersion() { + return supportedProtocolVersion; } /** @@ -95,6 +121,22 @@ public TusFileUploadService withMaxUploadSize(Long maxUploadSize) { return this; } + /** + * Set the maximum size allowed for an individual upload append (PATCH) request in IETF Resumable + * Uploads. + * + * @param maxAppendSize The maximum append size limit in bytes + * @return The current service + */ + public TusFileUploadService withMaxAppendSize(Long maxAppendSize) { + if (maxAppendSize != null) { + Validate.exclusiveBetween( + 0, Long.MAX_VALUE, maxAppendSize, "The max append size must be bigger than 0"); + } + this.uploadStorageService.setMaxAppendSize(maxAppendSize); + return this; + } + /** * Provide a custom {@link UploadIdFactory} implementation that should be used to generate * identifiers for the different uploads. Example implementation are {@link @@ -125,6 +167,7 @@ public TusFileUploadService withUploadStorageService(UploadStorageService upload Objects.requireNonNull(uploadStorageService, "The UploadStorageService cannot be null"); // Copy over any previous configuration uploadStorageService.setMaxUploadSize(this.uploadStorageService.getMaxUploadSize()); + uploadStorageService.setMaxAppendSize(this.uploadStorageService.getMaxAppendSize()); uploadStorageService.setUploadExpirationPeriod( this.uploadStorageService.getUploadExpirationPeriod()); uploadStorageService.setIdFactory(this.idFactory); @@ -134,6 +177,15 @@ public TusFileUploadService withUploadStorageService(UploadStorageService upload return this; } + /** + * Get the current {@link UploadStorageService} configured on this service. + * + * @return The current {@link UploadStorageService} + */ + public UploadStorageService getUploadStorageService() { + return this.uploadStorageService; + } + /** * Provide a custom {@link UploadLockingService} implementation that should be used when * processing uploads. The upload locking service is responsible for locking an upload that is @@ -317,7 +369,6 @@ public void process( TusServletRequest request = new TusServletRequest(servletRequest, isChunkedTransferDecodingEnabled); - request.setAttribute("me.desair.tus.uploadLockingService", uploadLockingService); TusServletResponse response = new TusServletResponse(servletResponse); try (UploadLock lock = acquireUploadLock(method, request.getRequestURI())) { @@ -457,37 +508,74 @@ public void cleanup() throws IOException { protected void processLockedRequest( HttpMethod method, TusServletRequest request, TusServletResponse response, String ownerKey) throws IOException { + ProtocolVersion detectedVersion = detectProtocolVersion(request); + try { - validateRequest(method, request, ownerKey); + validateRequest(method, request, ownerKey, detectedVersion); - executeProcessingByFeatures(method, request, response, ownerKey); + executeProcessingByFeatures(method, request, response, ownerKey, detectedVersion); } catch (TusException e) { - processTusException(method, request, response, ownerKey, e); + processTusException(method, request, response, ownerKey, e, detectedVersion); + } + } + + public ProtocolVersion detectProtocolVersion(HttpServletRequest request) { + if (supportedProtocolVersion == ProtocolVersion.TUS_1_0_0) { + return ProtocolVersion.TUS_1_0_0; + } + if (supportedProtocolVersion == ProtocolVersion.RUFH) { + return ProtocolVersion.RUFH; } + + if (request != null) { + if (request.getHeader(HttpHeader.TUS_RESUMABLE) != null) { + return ProtocolVersion.TUS_1_0_0; + } + if (request.getHeader(HttpHeader.UPLOAD_COMPLETE) != null + || request.getHeader(HttpHeader.UPLOAD_DRAFT) != null + || request.getHeader("upload-draft-interop-version") != null + || Strings.CS.startsWith( + request.getHeader(HttpHeader.CONTENT_TYPE), HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD)) { + return ProtocolVersion.RUFH; + } + } + return ProtocolVersion.TUS_1_0_0; } protected void executeProcessingByFeatures( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, - String ownerKey) + String ownerKey, + ProtocolVersion version) throws IOException, TusException { for (TusExtension feature : enabledFeatures.values()) { if (!servletRequest.isProcessedBy(feature)) { servletRequest.addProcessor(feature); - feature.process(method, servletRequest, servletResponse, uploadStorageService, ownerKey); + feature.process( + method, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + ownerKey, + version); } } } protected void validateRequest( - HttpMethod method, HttpServletRequest servletRequest, String ownerKey) + HttpMethod method, + HttpServletRequest servletRequest, + String ownerKey, + ProtocolVersion version) throws TusException, IOException { for (TusExtension feature : enabledFeatures.values()) { - feature.validate(method, servletRequest, uploadStorageService, ownerKey); + feature.validate( + method, servletRequest, uploadStorageService, uploadLockingService, ownerKey, version); } } @@ -496,7 +584,8 @@ protected void processTusException( TusServletRequest request, TusServletResponse response, String ownerKey, - TusException exception) + TusException exception, + ProtocolVersion version) throws IOException { int status = exception.getStatus(); @@ -509,17 +598,24 @@ protected void processTusException( status, message); + response.setStatus(status); + try { for (TusExtension feature : enabledFeatures.values()) { - if (!request.isProcessedBy(feature)) { request.addProcessor(feature); - feature.handleError(method, request, response, uploadStorageService, ownerKey); + feature.handleError( + method, + request, + response, + uploadStorageService, + uploadLockingService, + ownerKey, + version); } } - // Since an error occurred, the bytes we have written are probably not valid. So remove - // them. + // Since an error occurred, the bytes we have written are probably not valid. So remove them. UploadInfo uploadInfo = uploadStorageService.getUploadInfo(request.getRequestURI(), ownerKey); uploadStorageService.removeLastNumberOfBytes(uploadInfo, request.getBytesRead()); @@ -527,7 +623,12 @@ protected void processTusException( log.warn("An exception occurred while handling another exception", ex); } - response.sendError(status, message); + // If one of the features has already committed a response, we don't need to send an error + // response. Otherwise, we send the error response with the status and message from the + // exception. + if (!response.isCommitted()) { + response.sendError(status, message); + } } private void updateSupportedHttpMethods() { diff --git a/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java b/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java index d4e231e4..767fa519 100644 --- a/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java @@ -48,7 +48,7 @@ public void process( ChecksumAlgorithm checksumAlgorithm = checksumInfo.getAlgorithm(); String calculatedValue = servletRequest.getCalculatedChecksum(checksumAlgorithm); - if (!Strings.CS.equals(expectedValue, calculatedValue)) { + if (!Strings.CI.equals(expectedValue, calculatedValue)) { // throw an exception if the checksum is invalid. This will also trigger the removal // of any // bytes that were already saved diff --git a/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java b/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java index 1969a22f..2c107019 100644 --- a/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java @@ -44,6 +44,18 @@ public void process( UploadStorageService uploadStorageService, String ownerKey) throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService lockingService, + String ownerKey) + throws IOException, TusException { boolean found = true; UploadInfo uploadInfo = @@ -54,9 +66,6 @@ public void process( } else if (uploadInfo.isUploadInProgress()) { try { InputStream stream = servletRequest.getContentInputStream(); - UploadLockingService lockingService = - (UploadLockingService) - servletRequest.getAttribute("me.desair.tus.uploadLockingService"); if (lockingService != null) { InterruptibleInputStream interruptibleStream = new InterruptibleInputStream(stream); lockingService.registerInputStream(servletRequest.getRequestURI(), interruptibleStream); diff --git a/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java b/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java new file mode 100644 index 00000000..7a346d0a --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java @@ -0,0 +1,279 @@ +package me.desair.tus.server.rufh; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.util.TusServletResponse; + +/** + * Value object representing RFC 7807 Problem Details for Resumable Uploads for HTTP (RUFH), + * specified in Section 8 of draft-ietf-httpbis-resumable-upload. + * + *

Provides structured data modeling and JSON formatting for problem detail responses. + */ +public class HttpProblemDetails { + + private final int status; + private final String type; + private final String title; + private final String detail; + private final Map extraFields; + + /** + * Construct an immutable RFC 7807 problem details instance. + * + * @param status HTTP response status code + * @param type Problem type URI reference (defaults to "about:blank" if null) + * @param title Short summary of the problem type + * @param detail Human-readable explanation specific to this occurrence of the problem + * @param extraFields Additional extension members (e.g. expected-offset) + */ + public HttpProblemDetails( + int status, String type, String title, String detail, Map extraFields) { + this.status = status; + this.type = type != null ? type : "about:blank"; + this.title = title; + this.detail = detail; + this.extraFields = + extraFields != null + ? Collections.unmodifiableMap(new LinkedHashMap<>(extraFields)) + : Collections.emptyMap(); + } + + /** + * Factory method to create a problem details instance. + * + * @param status HTTP response status code + * @param type Problem type URI reference + * @param title Short summary of the problem type + * @param detail Human-readable explanation + * @param extraFields Additional extension members + * @return A new HttpProblemDetails instance + */ + public static HttpProblemDetails of( + int status, String type, String title, String detail, Map extraFields) { + return new HttpProblemDetails(status, type, title, detail, extraFields); + } + + /** + * Create an Offset Mismatch (409 Conflict) problem details response for RUFH append requests. + * + *

Reference: Section 7.1 (Mismatching Offset) of draft-ietf-httpbis-resumable-upload-11: "This + * section defines the 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset' + * problem type." + * + * @param expectedOffset Expected server byte offset + * @param providedOffset Provided Upload-Offset header value + * @return HttpProblemDetails instance configured for offset mismatch + */ + public static HttpProblemDetails forOffsetMismatch(long expectedOffset, Long providedOffset) { + Map extra = new LinkedHashMap<>(); + extra.put("expected-offset", expectedOffset); + return new HttpProblemDetails( + HttpServletResponse.SC_CONFLICT, + "https://iana.org/assignments/http-problem-types#mismatching-upload-offset", + "Offset Mismatch", + "The provided Upload-Offset does not match the server's current offset", + extra); + } + + /** + * Create an Upload Completed problem details response. + * + *

Reference: Section 7.2 (Completed Upload) of draft-ietf-httpbis-resumable-upload-11: "This + * section defines the 'https://iana.org/assignments/http-problem-types#completed-upload' problem + * type." + * + * @param status HTTP response status code (e.g. 400 Bad Request) + * @return HttpProblemDetails instance configured for completed upload + */ + public static HttpProblemDetails forCompletedUpload(int status) { + return new HttpProblemDetails( + status, + "https://iana.org/assignments/http-problem-types#completed-upload", + "Upload Completed", + "The upload resource is already completed and cannot be modified", + null); + } + + /** + * Create an Inconsistent Length (400 Bad Request) problem details response. + * + *

Reference: Section 7.3 (Inconsistent Length) of draft-ietf-httpbis-resumable-upload-11: + * "This section defines the + * 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length' problem type." + * + * @return HttpProblemDetails instance configured for inconsistent upload length + */ + public static HttpProblemDetails forInconsistentLength() { + return new HttpProblemDetails( + HttpServletResponse.SC_BAD_REQUEST, + "https://iana.org/assignments/http-problem-types#inconsistent-upload-length", + "Inconsistent Upload Length", + "The provided Upload-Length does not match existing metadata", + null); + } + + /** + * Send problem details response directly to a TusServletResponse. + * + * @param response The servlet response + * @param status HTTP response status code + * @param type Problem type URI reference + * @param title Short summary + * @param detail Human-readable explanation + * @param extraFields Additional extension members + * @throws IOException When writing response fails + */ + public static void sendProblemDetails( + TusServletResponse response, + int status, + String type, + String title, + String detail, + Map extraFields) + throws IOException { + of(status, type, title, detail, extraFields).writeTo(response); + } + + /** + * Helper to send offset mismatch problem details directly. + * + * @param response Servlet response + * @param expectedOffset Expected server offset + * @param providedOffset Provided upload offset + * @throws IOException When writing response fails + */ + public static void sendOffsetMismatch( + TusServletResponse response, long expectedOffset, Long providedOffset) throws IOException { + forOffsetMismatch(expectedOffset, providedOffset).writeTo(response); + } + + /** + * Helper to send completed upload problem details directly. + * + * @param response Servlet response + * @param status HTTP status code + * @throws IOException When writing response fails + */ + public static void sendCompletedUpload(TusServletResponse response, int status) + throws IOException { + forCompletedUpload(status).writeTo(response); + } + + /** + * Helper to send inconsistent length problem details directly. + * + * @param response Servlet response + * @throws IOException When writing response fails + */ + public static void sendInconsistentLength(TusServletResponse response) throws IOException { + forInconsistentLength().writeTo(response); + } + + /** + * Serialize this problem details instance to a JSON string according to RFC 7807. + * + * @return Valid JSON string representation + */ + public String toJson() { + Map map = new LinkedHashMap<>(); + map.put("type", type); + map.put("title", title); + map.put("status", status); + if (detail != null) { + map.put("detail", detail); + } + if (extraFields != null && !extraFields.isEmpty()) { + map.putAll(extraFields); + } + return formatJson(map); + } + + /** + * Write this problem details instance to the HTTP servlet response. + * + * @param response Target HttpServletResponse + * @throws IOException When writing to response stream fails + */ + public void writeTo(HttpServletResponse response) throws IOException { + Objects.requireNonNull(response, "Response cannot be null"); + response.setStatus(status); + response.setHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PROBLEM_JSON); + response.getWriter().write(toJson()); + response.getWriter().flush(); + } + + /** + * Write this problem details instance to the TusServletResponse wrapper. + * + * @param response Target TusServletResponse + * @throws IOException When writing to response stream fails + */ + public void writeTo(TusServletResponse response) throws IOException { + Objects.requireNonNull(response, "Response cannot be null"); + response.setStatus(status); + response.setHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PROBLEM_JSON); + response.getWriter().write(toJson()); + response.getWriter().flush(); + } + + public int getStatus() { + return status; + } + + public String getType() { + return type; + } + + public String getTitle() { + return title; + } + + public String getDetail() { + return detail; + } + + public Map getExtraFields() { + return extraFields; + } + + private String formatJson(Map map) { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(","); + } + first = false; + sb.append("\"").append(escapeJsonString(entry.getKey())).append("\":"); + Object value = entry.getValue(); + if (value instanceof Number || value instanceof Boolean) { + sb.append(value); + } else { + sb.append("\"").append(escapeJsonString(String.valueOf(value))).append("\""); + } + } + sb.append("}"); + return sb.toString(); + } + + private String escapeJsonString(String value) { + if (value == null) { + return ""; + } + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/InterimResponseStrategy.java b/src/main/java/me/desair/tus/server/rufh/InterimResponseStrategy.java new file mode 100644 index 00000000..ee65413d --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/InterimResponseStrategy.java @@ -0,0 +1,19 @@ +package me.desair.tus.server.rufh; + +import me.desair.tus.server.util.TusServletResponse; + +/** + * Strategy interface to handle 104 (Upload Resumption) interim responses as specified in the IETF + * Resumable Uploads for HTTP specification. + */ +public interface InterimResponseStrategy { + + /** + * Send an interim 104 response if supported by the container/environment. + * + * @param response the response to write headers/status to + * @param uploadUri the location URI of the upload + * @param offset the current upload offset + */ + void sendInterimResponse(TusServletResponse response, String uploadUri, long offset); +} diff --git a/src/main/java/me/desair/tus/server/rufh/NoOpInterimResponseStrategy.java b/src/main/java/me/desair/tus/server/rufh/NoOpInterimResponseStrategy.java new file mode 100644 index 00000000..58476ccd --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/NoOpInterimResponseStrategy.java @@ -0,0 +1,15 @@ +package me.desair.tus.server.rufh; + +import me.desair.tus.server.util.TusServletResponse; + +/** + * Default no-op implementation of {@link InterimResponseStrategy} for environments that do not + * support HTTP 104 interim responses (e.g. standard Servlet containers). + */ +public class NoOpInterimResponseStrategy implements InterimResponseStrategy { + + @Override + public void sendInterimResponse(TusServletResponse response, String uploadUri, long offset) { + // Standard servlet containers do not support 104 interim responses; no-op. + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java new file mode 100644 index 00000000..96ad990a --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java @@ -0,0 +1,93 @@ +package me.desair.tus.server.rufh; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.RequestHandler; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.rufh.handler.RufhAppendPatchRequestHandler; +import me.desair.tus.server.rufh.handler.RufhCreationPostRequestHandler; +import me.desair.tus.server.rufh.handler.RufhDeleteRequestHandler; +import me.desair.tus.server.rufh.handler.RufhErrorHandler; +import me.desair.tus.server.rufh.handler.RufhHeadRequestHandler; +import me.desair.tus.server.rufh.handler.RufhOptionsRequestHandler; +import me.desair.tus.server.rufh.validation.RufhAppendValidator; +import me.desair.tus.server.rufh.validation.RufhCreationValidator; +import me.desair.tus.server.rufh.validation.RufhHeadHeaderValidator; +import me.desair.tus.server.rufh.validation.RufhSafePathValidator; +import me.desair.tus.server.rufh.validation.RufhUploadExistsValidator; +import me.desair.tus.server.util.AbstractTusExtension; + +/** + * Protocol extension implementing the official IETF Resumable Uploads for HTTP specification + * (draft-ietf-httpbis-resumable-upload: + * https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/). + * + *

Delegates validation and request processing to modular {@link RequestValidator} and {@link + * RequestHandler} implementations registered during initialization. + */ +public class ResumableUploadsForHttpProtocol extends AbstractTusExtension { + + public static final String EXTENSION_NAME = "resumable-uploads-for-http"; + + private InterimResponseStrategy interimResponseStrategy = new NoOpInterimResponseStrategy(); + + /** Construct a default ResumableUploadsForHttpProtocol extension instance. */ + public ResumableUploadsForHttpProtocol() { + super(); + } + + /** + * Configure an optional interim response strategy (e.g. for HTTP 104 interim responses). + * + * @param interimResponseStrategy The interim response strategy to use + * @return Current extension instance for method chaining + */ + public ResumableUploadsForHttpProtocol withInterimResponseStrategy( + InterimResponseStrategy interimResponseStrategy) { + if (interimResponseStrategy != null) { + this.interimResponseStrategy = interimResponseStrategy; + } + return this; + } + + @Override + public String getName() { + return EXTENSION_NAME; + } + + @Override + public Collection getMinimalSupportedHttpMethods() { + return Arrays.asList( + HttpMethod.OPTIONS, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PATCH, HttpMethod.DELETE); + } + + @Override + public boolean isApplicable(HttpMethod method, ProtocolVersion version) { + if (HttpMethod.OPTIONS.equals(method)) { + return true; + } + return version == ProtocolVersion.RUFH; + } + + @Override + protected void initValidators(List requestValidators) { + requestValidators.add(new RufhSafePathValidator()); + requestValidators.add(new RufhUploadExistsValidator()); + requestValidators.add(new RufhHeadHeaderValidator()); + requestValidators.add(new RufhCreationValidator()); + requestValidators.add(new RufhAppendValidator()); + } + + @Override + protected void initRequestHandlers(List requestHandlers) { + requestHandlers.add(new RufhOptionsRequestHandler()); + requestHandlers.add(new RufhHeadRequestHandler()); + requestHandlers.add(new RufhCreationPostRequestHandler(interimResponseStrategy)); + requestHandlers.add(new RufhAppendPatchRequestHandler()); + requestHandlers.add(new RufhDeleteRequestHandler()); + requestHandlers.add(new RufhErrorHandler()); + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java new file mode 100644 index 00000000..07302d9b --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java @@ -0,0 +1,101 @@ +package me.desair.tus.server.rufh.handler; + +import java.io.IOException; +import java.io.InputStream; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.InterruptibleInputStream; +import me.desair.tus.server.util.StructuredHeaderUtil; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** + * Request handler for data append requests via HTTP PATCH. + * + *

Appends request payload bytes to an existing upload resource, registers locking via {@link + * InterruptibleInputStream}, updates completion status, and sets Upload-Offset, Upload-Complete, + * and Upload-Draft response headers. + * + *

Reference: Section 4.4.2 (Append Response) & Appendix B (Draft Version Identification) of + * draft-ietf-httpbis-resumable-upload-11. + */ +public class RufhAppendPatchRequestHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.PATCH.equals(method); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey) + throws IOException, TusException { + + String requestUri = servletRequest.getRequestURI(); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); + + // If upload is null, this is a PATCH creation request and was handled by + // RufhCreationPostRequestHandler + if (uploadInfo == null) { + return; + } + + String uploadCompleteHeader = servletRequest.getHeader(HttpHeader.UPLOAD_COMPLETE); + Boolean uploadComplete = StructuredHeaderUtil.parseBoolean(uploadCompleteHeader); + + InputStream is = servletRequest.getContentInputStream(); + if (is != null) { + if (uploadLockingService != null) { + InterruptibleInputStream interruptibleStream = new InterruptibleInputStream(is); + uploadLockingService.registerInputStream(requestUri, interruptibleStream); + is = interruptibleStream; + } + UploadInfo appended = uploadStorageService.append(uploadInfo, is); + if (appended != null) { + uploadInfo = appended; + } + } + + boolean isFinished = Boolean.TRUE.equals(uploadComplete) || isUploadCompleted(uploadInfo); + if (isFinished) { + uploadInfo.setLength(uploadInfo.getOffset()); + uploadStorageService.update(uploadInfo); + } + + servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); + servletResponse.setHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(uploadInfo.getOffset())); + servletResponse.setHeader( + HttpHeader.UPLOAD_COMPLETE, StructuredHeaderUtil.formatBoolean(isFinished)); + + if (isFinished) { + servletResponse.setStatus(200); + } else { + servletResponse.setStatus(204); + } + } + + private boolean isUploadCompleted(UploadInfo uploadInfo) { + return uploadInfo != null && !uploadInfo.isUploadInProgress(); + } +} 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 new file mode 100644 index 00000000..27fb6a22 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java @@ -0,0 +1,164 @@ +package me.desair.tus.server.rufh.handler; + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.InterimResponseStrategy; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.InterruptibleInputStream; +import me.desair.tus.server.util.StructuredHeaderUtil; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** + * Request handler for upload creation requests via HTTP POST, PUT, or PATCH. + * + *

Handles upload initialization, optional 104 interim responses, payload byte streaming, lock + * registration via {@link InterruptibleInputStream}, and response headers. + * + *

Reference: Section 4.2 (Upload Creation) & Section 4.2.2 (Server Behavior) of + * draft-ietf-httpbis-resumable-upload-11. + */ +public class RufhCreationPostRequestHandler extends AbstractRequestHandler { + + private final InterimResponseStrategy interimResponseStrategy; + + public RufhCreationPostRequestHandler(InterimResponseStrategy interimResponseStrategy) { + this.interimResponseStrategy = interimResponseStrategy; + } + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.POST.equals(method) + || HttpMethod.PUT.equals(method) + || HttpMethod.PATCH.equals(method); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey) + throws IOException, TusException { + + if (HttpMethod.PATCH.equals(method) + && isExistingUpload(servletRequest, uploadStorageService, ownerKey)) { + // Existing upload on PATCH request is handled by RufhAppendPatchRequestHandler + return; + } + + UploadInfo uploadInfo = new UploadInfo(); + String uploadLengthHeader = servletRequest.getHeader(HttpHeader.UPLOAD_LENGTH); + Long uploadLength = StructuredHeaderUtil.parseInteger(uploadLengthHeader); + if (uploadLength != null && uploadLength >= 0) { + uploadInfo.setLength(uploadLength); + } + + String uploadCompleteHeader = servletRequest.getHeader(HttpHeader.UPLOAD_COMPLETE); + Boolean uploadComplete = StructuredHeaderUtil.parseBoolean(uploadCompleteHeader); + + uploadInfo = uploadStorageService.create(uploadInfo, ownerKey); + String uploadUri = getUploadUri(uploadInfo, servletRequest, uploadStorageService); + + if (interimResponseStrategy != null) { + interimResponseStrategy.sendInterimResponse(servletResponse, uploadUri, 0L); + } + + InputStream is = servletRequest.getContentInputStream(); + if (is != null && servletRequest.getContentLengthLong() != 0) { + if (uploadLockingService != null) { + InterruptibleInputStream interruptibleStream = new InterruptibleInputStream(is); + uploadLockingService.registerInputStream(uploadUri, interruptibleStream); + is = interruptibleStream; + } + UploadInfo appended = uploadStorageService.append(uploadInfo, is); + if (appended != null) { + uploadInfo = appended; + } + } + + servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); + + boolean isFinished = Boolean.TRUE.equals(uploadComplete) || isUploadCompleted(uploadInfo); + if (isFinished) { + uploadInfo.setLength(uploadInfo.getOffset()); + uploadStorageService.update(uploadInfo); + servletResponse.setStatus(200); + servletResponse.setHeader(HttpHeader.LOCATION, uploadUri); + servletResponse.setHeader( + HttpHeader.UPLOAD_COMPLETE, StructuredHeaderUtil.formatBoolean(true)); + servletResponse.setHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(uploadInfo.getOffset())); + } else { + servletResponse.setStatus(201); + servletResponse.setHeader(HttpHeader.LOCATION, uploadUri); + servletResponse.setHeader( + HttpHeader.UPLOAD_COMPLETE, StructuredHeaderUtil.formatBoolean(false)); + servletResponse.setHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(uploadInfo.getOffset())); + + addUploadLimitHeader(servletResponse, uploadStorageService); + } + } + + private boolean isExistingUpload( + TusServletRequest request, UploadStorageService uploadStorageService, String ownerKey) + throws IOException { + String requestUri = request.getRequestURI(); + return uploadStorageService.getUploadInfo(requestUri, ownerKey) != null; + } + + private boolean isUploadCompleted(UploadInfo uploadInfo) { + return uploadInfo != null && !uploadInfo.isUploadInProgress(); + } + + private String getUploadUri( + UploadInfo uploadInfo, + TusServletRequest servletRequest, + UploadStorageService storageService) { + String baseUri = storageService.getUploadUri(); + if (baseUri == null) { + baseUri = servletRequest.getRequestURI(); + } + String idStr = uploadInfo.getId() != null ? uploadInfo.getId().toString() : ""; + return baseUri + (baseUri.endsWith("/") ? "" : "/") + idStr; + } + + private void addUploadLimitHeader( + TusServletResponse response, UploadStorageService uploadStorageService) { + if (uploadStorageService == null) { + return; + } + Map limits = new LinkedHashMap<>(); + long maxSize = uploadStorageService.getMaxUploadSize(); + if (maxSize > 0) { + limits.put("max-size", maxSize); + } + Long maxAppendSize = uploadStorageService.getMaxAppendSize(); + if (maxAppendSize != null && maxAppendSize > 0) { + limits.put("max-append-size", maxAppendSize); + } + if (!limits.isEmpty()) { + response.setHeader(HttpHeader.UPLOAD_LIMIT, StructuredHeaderUtil.formatDictionary(limits)); + } + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java new file mode 100644 index 00000000..1056f695 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java @@ -0,0 +1,57 @@ +package me.desair.tus.server.rufh.handler; + +import java.io.IOException; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** + * Request handler for upload cancellation requests via HTTP DELETE. + * + *

Terminates the upload resource on storage and responds with HTTP 204 No Content. + * + *

Reference: Section 4.5 (Upload Cancellation) & Section 4.5.2 (Server Behavior) of + * draft-ietf-httpbis-resumable-upload-11. + */ +public class RufhDeleteRequestHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.DELETE.equals(method); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey) + throws IOException, TusException { + + String requestUri = servletRequest.getRequestURI(); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); + + if (uploadInfo != null) { + uploadStorageService.terminateUpload(uploadInfo); + } + servletResponse.setStatus(204); + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java new file mode 100644 index 00000000..f3a15f84 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java @@ -0,0 +1,89 @@ +package me.desair.tus.server.rufh.handler; + +import java.io.IOException; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.StructuredHeaderUtil; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** + * Error request handler that processes validation and processing failures for RUFH requests. + * + *

Formats offset mismatch errors (HTTP 409 Conflict), completed upload errors (HTTP 400 Bad + * Request), and inconsistent upload length errors (HTTP 400 Bad Request) as RFC 7807 Problem + * Details JSON. + * + *

Reference: Section 7 (Problem Types) of draft-ietf-httpbis-resumable-upload-11: + * + *

    + *
  • Section 7.1: Mismatching Offset (409 Conflict) + *
  • Section 7.2: Completed Upload (400 Bad Request) + *
  • Section 7.3: Inconsistent Length (400 Bad Request) + *
+ */ +public class RufhErrorHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return true; + } + + @Override + public boolean isErrorHandler() { + return true; + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey) + throws IOException, TusException { + + int status = servletResponse.getStatus(); + if (status == 409) { + // Section 7.1: Mismatching Offset + UploadInfo uploadInfo = + uploadStorageService.getUploadInfo(servletRequest.getRequestURI(), ownerKey); + long expectedOffset = + (uploadInfo != null && uploadInfo.getOffset() != null) ? uploadInfo.getOffset() : 0L; + Long providedOffset = + StructuredHeaderUtil.parseInteger(servletRequest.getHeader(HttpHeader.UPLOAD_OFFSET)); + long provided = providedOffset != null ? providedOffset : 0L; + + HttpProblemDetails.forOffsetMismatch(expectedOffset, provided).writeTo(servletResponse); + + } else if (status == 400) { + UploadInfo uploadInfo = + uploadStorageService.getUploadInfo(servletRequest.getRequestURI(), ownerKey); + if (uploadInfo != null && !uploadInfo.isUploadInProgress()) { + // Section 7.2: Completed Upload + HttpProblemDetails.forCompletedUpload(400).writeTo(servletResponse); + } else { + // Section 7.3: Inconsistent Length + HttpProblemDetails.forInconsistentLength().writeTo(servletResponse); + } + } + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java new file mode 100644 index 00000000..10f061e0 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java @@ -0,0 +1,89 @@ +package me.desair.tus.server.rufh.handler; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.StructuredHeaderUtil; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** + * Request handler for HTTP HEAD status retrieval requests. + * + *

Sets Upload-Offset, Upload-Complete, Upload-Length, Upload-Limit, Upload-Draft, and + * Cache-Control headers on the response. + * + *

Reference: Section 4.3 (Offset Retrieval) & Appendix B (Draft Version Identification) of + * draft-ietf-httpbis-resumable-upload-11. + */ +public class RufhHeadRequestHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.HEAD.equals(method); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey) + throws IOException { + + String requestUri = servletRequest.getRequestURI(); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); + + servletResponse.setStatus(204); + servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); + servletResponse.setHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(uploadInfo.getOffset())); + servletResponse.setHeader( + HttpHeader.UPLOAD_COMPLETE, + StructuredHeaderUtil.formatBoolean(!uploadInfo.isUploadInProgress())); + + if (uploadInfo.hasLength()) { + servletResponse.setHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(uploadInfo.getLength())); + } + + addUploadLimitHeader(servletResponse, uploadStorageService); + servletResponse.setHeader(HttpHeader.CACHE_CONTROL, "no-store"); + } + + private void addUploadLimitHeader( + TusServletResponse response, UploadStorageService uploadStorageService) { + if (uploadStorageService == null) { + return; + } + Map limits = new LinkedHashMap<>(); + long maxSize = uploadStorageService.getMaxUploadSize(); + if (maxSize > 0) { + limits.put("max-size", maxSize); + } + Long maxAppendSize = uploadStorageService.getMaxAppendSize(); + if (maxAppendSize != null && maxAppendSize > 0) { + limits.put("max-append-size", maxAppendSize); + } + if (!limits.isEmpty()) { + response.setHeader(HttpHeader.UPLOAD_LIMIT, StructuredHeaderUtil.formatDictionary(limits)); + } + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java new file mode 100644 index 00000000..c5208c42 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java @@ -0,0 +1,79 @@ +package me.desair.tus.server.rufh.handler; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.StructuredHeaderUtil; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** + * Request handler for HTTP OPTIONS feature discovery requests. + * + *

Sets Accept-Patch, Upload-Draft, and Upload-Limit headers on the response. + * + *

Reference: Section 4.1.4 (Limits) & Appendix B (Draft Version Identification) of + * draft-ietf-httpbis-resumable-upload-11. + */ +public class RufhOptionsRequestHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.OPTIONS.equals(method); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey) + throws IOException { + + servletResponse.setHeader( + HttpHeader.ACCEPT_PATCH, + HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD + ", application/offset+octet-stream"); + servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); + + addUploadLimitHeader(servletResponse, uploadStorageService); + servletResponse.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + private void addUploadLimitHeader( + TusServletResponse response, UploadStorageService uploadStorageService) { + if (uploadStorageService == null) { + return; + } + Map limits = new LinkedHashMap<>(); + long maxSize = uploadStorageService.getMaxUploadSize(); + if (maxSize > 0) { + limits.put("max-size", maxSize); + } + Long maxAppendSize = uploadStorageService.getMaxAppendSize(); + if (maxAppendSize != null && maxAppendSize > 0) { + limits.put("max-append-size", maxAppendSize); + } + if (!limits.isEmpty()) { + response.setHeader(HttpHeader.UPLOAD_LIMIT, StructuredHeaderUtil.formatDictionary(limits)); + } + } +} 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 new file mode 100644 index 00000000..3772c0cb --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java @@ -0,0 +1,119 @@ +package me.desair.tus.server.rufh.validation; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.StructuredHeaderUtil; +import org.apache.commons.lang3.Strings; + +/** + * Request validator checking data append requests via HTTP PATCH. + * + *

Validates Content-Type headers, resource existence, completed upload status, payload limits, + * Upload-Offset equality, and Upload-Length compliance. + * + *

Reference: Section 4.4.1 (Append Request) & Section 4.4.2 (Append Response) of + * draft-ietf-httpbis-resumable-upload-11: + * + *

    + *
  • "If the Upload-Offset request header field value does not match the current offset... the + * upload resource MUST reject the request with a 409 (Conflict) status code." + *
  • "If the length is known, the server MUST prevent the offset from exceeding the upload + * length by rejecting the request once the offset exceeds the length..." + *
+ */ +public class RufhAppendValidator implements RequestValidator { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.PATCH.equals(method); + } + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException, IOException { + + String requestUri = request.getRequestURI(); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); + + // If upload is null, this might be a PATCH creation request. Creation validation handles it. + if (uploadInfo == null) { + return; + } + + String contentType = request.getHeader(HttpHeader.CONTENT_TYPE); + if (!Strings.CS.startsWith(contentType, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD) + && !Strings.CS.startsWith(contentType, "application/offset+octet-stream")) { + throw new TusException(415, "Unsupported Content-Type for append request"); + } + + if (!uploadInfo.isUploadInProgress()) { + throw new TusException(400, "Upload resource is already completed"); + } + + Long maxAppendSize = uploadStorageService.getMaxAppendSize(); + long contentLength = request.getContentLengthLong(); + if (maxAppendSize != null + && maxAppendSize > 0 + && contentLength > 0 + && contentLength > maxAppendSize) { + throw new TusException( + 413, + "The request payload size (" + + contentLength + + ") exceeds the maximum allowed append size (" + + maxAppendSize + + ")"); + } + + String offsetHeader = request.getHeader(HttpHeader.UPLOAD_OFFSET); + Long providedOffset = StructuredHeaderUtil.parseInteger(offsetHeader); + if (providedOffset == null) { + throw new TusException(400, "Missing or invalid Upload-Offset header"); + } + + long currentOffset = uploadInfo.getOffset(); + if (providedOffset != currentOffset) { + throw new TusException( + 409, + "Upload-Offset " + providedOffset + " does not match server offset " + currentOffset); + } + + // Section 4.4.2: Prevent offset from exceeding upload length if length is known + if (uploadInfo.hasLength() && contentLength > 0) { + if (currentOffset + contentLength > uploadInfo.getLength()) { + throw new TusException( + 400, + "Appended content length (" + + contentLength + + ") pushes total offset past declared upload length (" + + uploadInfo.getLength() + + ")"); + } + } + + // Section 4.1.3: Validate consistency of Upload-Length if provided in append request + String uploadLengthHeader = request.getHeader(HttpHeader.UPLOAD_LENGTH); + Long providedLength = StructuredHeaderUtil.parseInteger(uploadLengthHeader); + if (providedLength != null && uploadInfo.hasLength()) { + if (!providedLength.equals(uploadInfo.getLength())) { + throw new TusException( + 400, + "Provided Upload-Length (" + + providedLength + + ") does not match existing upload length (" + + uploadInfo.getLength() + + ")"); + } + } + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java new file mode 100644 index 00000000..dad31f26 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java @@ -0,0 +1,78 @@ +package me.desair.tus.server.rufh.validation; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.StructuredHeaderUtil; + +/** + * Request validator checking creation request limits (max upload length and max append payload + * size) and length consistency. + * + *

Reference: Section 4.1.3 (Length) & Section 4.2 (Upload Creation) of + * draft-ietf-httpbis-resumable-upload-11: "If indicators (1) [Upload-Complete: ?1 with + * Content-Length] and (2) [Upload-Length] are both present in the same request, their indicated + * lengths MUST match." + */ +public class RufhCreationValidator implements RequestValidator { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method); + } + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException, IOException { + + String uploadLengthHeader = request.getHeader(HttpHeader.UPLOAD_LENGTH); + Long uploadLength = StructuredHeaderUtil.parseInteger(uploadLengthHeader); + String uploadCompleteHeader = request.getHeader(HttpHeader.UPLOAD_COMPLETE); + Boolean uploadComplete = StructuredHeaderUtil.parseBoolean(uploadCompleteHeader); + long contentLength = request.getContentLengthLong(); + + // Section 4.1.3: Validate consistency between Upload-Length and Content-Length when + // Upload-Complete: ?1 + if (uploadLength != null && Boolean.TRUE.equals(uploadComplete) && contentLength >= 0) { + if (uploadLength != contentLength) { + throw new TusException( + 400, + "The provided Upload-Length (" + + uploadLength + + ") does not match Content-Length (" + + contentLength + + ")"); + } + } + + Long maxUploadSize = uploadStorageService.getMaxUploadSize(); + if (maxUploadSize != null + && maxUploadSize > 0 + && uploadLength != null + && uploadLength > maxUploadSize) { + throw new TusException(413, "The requested upload length exceeds the maximum allowed size"); + } + + Long maxAppendSize = uploadStorageService.getMaxAppendSize(); + if (maxAppendSize != null + && maxAppendSize > 0 + && contentLength > 0 + && contentLength > maxAppendSize) { + throw new TusException( + 413, + "The request payload size (" + + contentLength + + ") exceeds the maximum allowed append size (" + + maxAppendSize + + ")"); + } + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidator.java new file mode 100644 index 00000000..a46be60b --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidator.java @@ -0,0 +1,40 @@ +package me.desair.tus.server.rufh.validation; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; + +/** + * Request validator checking that HEAD status requests do not contain Upload-Offset or + * Upload-Complete header fields. + * + *

Reference: Section 4.3.1 (Client Behavior - Offset Retrieval) of + * draft-ietf-httpbis-resumable-upload-11: "The request MUST NOT contain Upload-Offset or + * Upload-Complete header fields." + */ +public class RufhHeadHeaderValidator implements RequestValidator { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.HEAD.equals(method); + } + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException, IOException { + + if (request.getHeader(HttpHeader.UPLOAD_OFFSET) != null + || request.getHeader(HttpHeader.UPLOAD_COMPLETE) != null) { + throw new TusException( + 400, "HEAD request MUST NOT contain Upload-Offset or Upload-Complete header field"); + } + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhSafePathValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhSafePathValidator.java new file mode 100644 index 00000000..22eb9efc --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhSafePathValidator.java @@ -0,0 +1,37 @@ +package me.desair.tus.server.rufh.validation; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; + +/** + * Request validator that checks the request URI for unsafe path traversal components (e.g. ".." or + * null bytes). + * + *

Reference: Section 13 (Security Considerations) of draft-ietf-httpbis-resumable-upload-11: + * "Uploaded representation data and its metadata are untrusted input." + */ +public class RufhSafePathValidator implements RequestValidator { + + @Override + public boolean supports(HttpMethod method) { + return method != null; + } + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException, IOException { + + String path = request.getRequestURI(); + if (path != null && (path.contains("..") || path.contains("\0"))) { + throw new TusException(400, "Invalid or unsafe path component: " + path); + } + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidator.java new file mode 100644 index 00000000..d76d464d --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidator.java @@ -0,0 +1,39 @@ +package me.desair.tus.server.rufh.validation; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; + +/** + * Request validator verifying that the target upload resource exists for status querying, data + * appending, or cancellation requests. + * + *

Reference: Section 4.3 (Offset Retrieval), Section 4.4 (Upload Append), and Section 4.5 + * (Upload Cancellation) of draft-ietf-httpbis-resumable-upload-11. + */ +public class RufhUploadExistsValidator implements RequestValidator { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.HEAD.equals(method) || HttpMethod.DELETE.equals(method); + } + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException, IOException { + + String requestUri = request.getRequestURI(); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); + if (uploadInfo == null) { + throw new TusException(404, "Upload resource not found"); + } + } +} diff --git a/src/main/java/me/desair/tus/server/upload/UploadStorageService.java b/src/main/java/me/desair/tus/server/upload/UploadStorageService.java index 39a98f3a..f98827cc 100644 --- a/src/main/java/me/desair/tus/server/upload/UploadStorageService.java +++ b/src/main/java/me/desair/tus/server/upload/UploadStorageService.java @@ -61,6 +61,26 @@ public interface UploadStorageService { */ long getMaxUploadSize(); + /** + * Limit the maximum append payload size to the given value in bytes. + * + * @param maxAppendSize The maximum append limit to set + */ + default void setMaxAppendSize(Long maxAppendSize) { + // Default no-op for third-party implementations + } + + /** + * Get the maximum append payload size configured on this storage service. If not explicitly set, + * defaults to the maximum upload size limit if set. + * + * @return The maximum append size in bytes, or null if no maximum append limit applies + */ + default Long getMaxAppendSize() { + long maxUpload = getMaxUploadSize(); + return maxUpload > 0 ? maxUpload : null; + } + /** * Create an upload location with the given upload information. * 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 52a8e80d..e5c6977f 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 @@ -45,6 +45,7 @@ public class DiskStorageService extends AbstractDiskBasedService implements Uplo private static final String DATA_FILE = "data"; private Long maxUploadSize = null; + private Long maxAppendSize = null; private Long uploadExpirationPeriod = null; private UploadIdFactory idFactory; private UploadConcatenationService uploadConcatenationService; @@ -77,6 +78,20 @@ public long getMaxUploadSize() { return maxUploadSize == null ? 0 : maxUploadSize; } + @Override + public void setMaxAppendSize(Long maxAppendSize) { + this.maxAppendSize = (maxAppendSize != null && maxAppendSize > 0 ? maxAppendSize : null); + } + + @Override + public Long getMaxAppendSize() { + if (maxAppendSize != null && maxAppendSize > 0) { + return maxAppendSize; + } + long maxUpload = getMaxUploadSize(); + return maxUpload > 0 ? maxUpload : null; + } + @Override public void setUploadDeduplicationEnabled(boolean enabled) { this.isUploadDeduplicationEnabled = enabled; diff --git a/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java b/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java index 63ca0da8..c00b6e75 100644 --- a/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java +++ b/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java @@ -5,10 +5,12 @@ import java.util.LinkedList; import java.util.List; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.RequestHandler; import me.desair.tus.server.RequestValidator; import me.desair.tus.server.TusExtension; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; /** Abstract class to implement a tus extension using validators and request handlers. */ @@ -33,6 +35,23 @@ public void validate( UploadStorageService uploadStorageService, String ownerKey) throws TusException, IOException { + validate( + method, servletRequest, uploadStorageService, null, ownerKey, ProtocolVersion.TUS_1_0_0); + } + + @Override + public void validate( + HttpMethod method, + HttpServletRequest servletRequest, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version) + throws TusException, IOException { + + if (!isApplicable(method, version)) { + return; + } for (RequestValidator requestValidator : requestValidators) { if (requestValidator.supports(method)) { @@ -49,11 +68,40 @@ public void process( UploadStorageService uploadStorageService, String ownerKey) throws IOException, TusException { + process( + method, + servletRequest, + servletResponse, + uploadStorageService, + null, + ownerKey, + ProtocolVersion.TUS_1_0_0); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version) + throws IOException, TusException { + + if (!isApplicable(method, version)) { + return; + } for (RequestHandler requestHandler : requestHandlers) { if (requestHandler.supports(method)) { requestHandler.process( - method, servletRequest, servletResponse, uploadStorageService, ownerKey); + method, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + ownerKey); } } } @@ -66,10 +114,29 @@ public void handleError( UploadStorageService uploadStorageService, String ownerKey) throws IOException, TusException { + handleError( + method, request, response, uploadStorageService, null, ownerKey, ProtocolVersion.TUS_1_0_0); + } + + @Override + public void handleError( + HttpMethod method, + TusServletRequest request, + TusServletResponse response, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version) + throws IOException, TusException { + + if (!isApplicable(method, version)) { + return; + } for (RequestHandler requestHandler : requestHandlers) { if (requestHandler.supports(method) && requestHandler.isErrorHandler()) { - requestHandler.process(method, request, response, uploadStorageService, ownerKey); + requestHandler.process( + method, request, response, uploadStorageService, uploadLockingService, ownerKey); } } } diff --git a/src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java b/src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java new file mode 100644 index 00000000..f82c7f31 --- /dev/null +++ b/src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java @@ -0,0 +1,169 @@ +package me.desair.tus.server.util; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.commons.lang3.Strings; + +/** + * Utility class for parsing and serializing RFC 9651 Structured Field Values used in IETF Resumable + * Uploads for HTTP (RUFH). + * + *

RFC 9651 defines standardized data types for HTTP headers, including: + * + *

    + *
  • Booleans (Section 3.3.6): Encoded as {@code ?1} (true) or {@code ?0} (false). + *
  • Integers (Section 3.3.1): Encoded as signed 64-bit decimal integers. + *
  • Dictionaries (Section 3.2): Comma-separated list of key-value pairs (e.g. {@code + * max-size=100, max-append-size=500}). Keys without explicit values default to boolean {@code + * true}. + *
+ */ +public class StructuredHeaderUtil { + + private StructuredHeaderUtil() { + // Utility class + } + + /** + * Parse a Structured Header Boolean item according to RFC 9651 Section 3.3.6. + * + * @param headerValue The header value to parse (e.g. "?1" or "?0") + * @return {@link Boolean#TRUE} for "?1", {@link Boolean#FALSE} for "?0", or {@code null} if + * invalid or blank + */ + public static Boolean parseBoolean(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return null; + } + String trimmed = headerValue.trim(); + if (Strings.CS.equals("?1", trimmed)) { + return Boolean.TRUE; + } else if (Strings.CS.equals("?0", trimmed)) { + return Boolean.FALSE; + } + return null; + } + + /** + * Format a boolean into an RFC 9651 Structured Header Boolean item string. + * + * @param value The boolean value to format + * @return "?1" for true, "?0" for false + */ + public static String formatBoolean(boolean value) { + return value ? "?1" : "?0"; + } + + /** + * Parse a Structured Header Integer item according to RFC 9651 Section 3.3.1. + * + * @param headerValue The header value string (e.g. "12500") + * @return Parsed {@link Long} value, or {@code null} if blank or not a valid decimal integer + */ + public static Long parseInteger(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return null; + } + try { + return Long.parseLong(headerValue.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Parse a Structured Header Dictionary according to RFC 9651 Section 3.2. + * + *

Parses comma-separated dictionary members (e.g., {@code max-size=1000000, + * max-append-size=50000}). Members without explicit values (e.g., {@code key}) implicitly + * evaluate to boolean {@code true}. + * + * @param headerValue The dictionary header value string + * @return Ordered {@link Map} of member key names to typed values ({@link Long}, {@link Boolean}, + * or {@link String}) + */ + public static Map parseDictionary(String headerValue) { + Map dictionary = new LinkedHashMap<>(); + if (headerValue == null || headerValue.isBlank()) { + return dictionary; + } + + // Split dictionary members by comma (RFC 9651 Section 3.2) + String[] members = headerValue.split(","); + for (String member : members) { + String trimmedMember = member.trim(); + if (trimmedMember.isEmpty()) { + continue; + } + + int eqIdx = trimmedMember.indexOf('='); + if (eqIdx > 0) { + // Key-value pair member + String key = trimmedMember.substring(0, eqIdx).trim(); + String valStr = trimmedMember.substring(eqIdx + 1).trim(); + + // Attempt parsing as structured boolean item first (?1 / ?0) + Boolean boolVal = parseBoolean(valStr); + if (boolVal != null) { + dictionary.put(key, boolVal); + } else { + // Attempt parsing as structured integer item + Long intVal = parseInteger(valStr); + if (intVal != null) { + dictionary.put(key, intVal); + } else { + // Fallback to sanitized string value + dictionary.put(key, sanitizeHeaderValue(valStr)); + } + } + } else { + // According to RFC 9651 Section 3.2, a member with no value defaults to boolean true + dictionary.put(trimmedMember, Boolean.TRUE); + } + } + return dictionary; + } + + /** + * Format a map of key-value limits/entries into an RFC 9651 Structured Header Dictionary string. + * + * @param dictionary Map of dictionary keys and values + * @return Structured header dictionary string (e.g. "max-size=100, max-append-size=50") + */ + public static String formatDictionary(Map dictionary) { + if (dictionary == null || dictionary.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : dictionary.entrySet()) { + if (entry.getValue() == null) { + continue; + } + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(sanitizeHeaderValue(entry.getKey())).append("="); + Object val = entry.getValue(); + if (val instanceof Boolean) { + sb.append(formatBoolean((Boolean) val)); + } else { + sb.append(sanitizeHeaderValue(val.toString())); + } + } + return sb.toString(); + } + + /** + * Sanitize header input to prevent CRLF injection and HTTP response splitting attacks. Strips CR + * ('\r'), LF ('\n'), and null ('\0') characters. + * + * @param value Raw string input + * @return Sanitized string with CR/LF/null characters removed + */ + public static String sanitizeHeaderValue(String value) { + if (value == null) { + return null; + } + return value.replace("\r", "").replace("\n", "").replace("\0", "").trim(); + } +} diff --git a/src/test/java/me/desair/tus/server/CoverageGapTest.java b/src/test/java/me/desair/tus/server/CoverageGapTest.java new file mode 100644 index 00000000..66064226 --- /dev/null +++ b/src/test/java/me/desair/tus/server/CoverageGapTest.java @@ -0,0 +1,238 @@ +package me.desair.tus.server; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.upload.disk.DiskStorageService; +import me.desair.tus.server.util.AbstractTusExtension; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Test; + +public class CoverageGapTest { + + private static class TestTusExtensionDirect implements TusExtension { + boolean validateCalled = false; + boolean processCalled = false; + boolean handleErrorCalled = false; + + @Override + public String getName() { + return "test"; + } + + @Override + public java.util.Collection getMinimalSupportedHttpMethods() { + return java.util.Collections.emptyList(); + } + + @Override + public void validate( + HttpMethod method, + HttpServletRequest servletRequest, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException, IOException { + validateCalled = true; + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException { + processCalled = true; + } + + @Override + public void handleError( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException { + handleErrorCalled = true; + } + } + + private static class DummyAbstractExtension extends AbstractTusExtension { + boolean handleError7Called = false; + + @Override + public String getName() { + return "dummy"; + } + + @Override + public java.util.Collection getMinimalSupportedHttpMethods() { + return java.util.Collections.emptyList(); + } + + @Override + protected void initValidators(java.util.List requestValidators) {} + + @Override + protected void initRequestHandlers(java.util.List requestHandlers) {} + + @Override + public void handleError( + HttpMethod method, + TusServletRequest request, + TusServletResponse response, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version) + throws IOException, TusException { + handleError7Called = true; + } + } + + @Test + public void testTusExtensionDefaultMethods() throws Exception { + TestTusExtensionDirect extension = new TestTusExtensionDirect(); + + extension.validate(HttpMethod.POST, null, null, null, null, ProtocolVersion.TUS_1_0_0); + assertThat(extension.validateCalled, is(true)); + + extension.process(HttpMethod.POST, null, null, null, null, null, ProtocolVersion.TUS_1_0_0); + assertThat(extension.processCalled, is(true)); + + extension.handleError(HttpMethod.POST, null, null, null, null, null, ProtocolVersion.TUS_1_0_0); + assertThat(extension.handleErrorCalled, is(true)); + } + + @Test + public void testAbstractTusExtensionHandleError5Args() throws Exception { + DummyAbstractExtension ext = new DummyAbstractExtension(); + ext.handleError(HttpMethod.POST, null, null, null, null); + assertThat(ext.handleError7Called, is(true)); + } + + @Test + public void testTusFileUploadServiceWithMaxAppendSizeNull() { + TusFileUploadService service = new TusFileUploadService(); + service.withMaxAppendSize(null); + assertThat(service.detectProtocolVersion(null), is(ProtocolVersion.TUS_1_0_0)); + } + + @Test + public void testUploadStorageServiceDefaultMaxAppendSize() { + UploadStorageService storage = + new UploadStorageService() { + @Override + public me.desair.tus.server.upload.UploadInfo getUploadInfo( + String uploadUrl, String ownerKey) { + return null; + } + + @Override + public me.desair.tus.server.upload.UploadInfo getUploadInfo( + me.desair.tus.server.upload.UploadId id) { + return null; + } + + @Override + public String getUploadUri() { + return "/"; + } + + @Override + public me.desair.tus.server.upload.UploadInfo append( + me.desair.tus.server.upload.UploadInfo upload, java.io.InputStream inputStream) { + return null; + } + + @Override + public void setMaxUploadSize(Long maxUploadSize) {} + + @Override + public long getMaxUploadSize() { + return 0; + } + + @Override + public me.desair.tus.server.upload.UploadInfo create( + me.desair.tus.server.upload.UploadInfo info, String ownerKey) { + return null; + } + + @Override + public void update(me.desair.tus.server.upload.UploadInfo uploadInfo) {} + + @Override + public java.io.InputStream getUploadedBytes(String uploadUri, String ownerKey) { + return null; + } + + @Override + public java.io.InputStream getUploadedBytes(me.desair.tus.server.upload.UploadId id) { + return null; + } + + @Override + public void copyUploadTo( + me.desair.tus.server.upload.UploadInfo info, java.io.OutputStream outputStream) {} + + @Override + public void cleanupExpiredUploads(UploadLockingService uploadLockingService) {} + + @Override + public void removeLastNumberOfBytes( + me.desair.tus.server.upload.UploadInfo uploadInfo, long byteCount) {} + + @Override + public void terminateUpload(me.desair.tus.server.upload.UploadInfo uploadInfo) {} + + @Override + public Long getUploadExpirationPeriod() { + return null; + } + + @Override + public void setUploadExpirationPeriod(Long uploadExpirationPeriod) {} + + @Override + public void setUploadConcatenationService( + me.desair.tus.server.upload.concatenation.UploadConcatenationService + concatenationService) {} + + @Override + public me.desair.tus.server.upload.concatenation.UploadConcatenationService + getUploadConcatenationService() { + return null; + } + + @Override + public void setIdFactory(me.desair.tus.server.upload.UploadIdFactory idFactory) {} + }; + + assertThat(storage.getMaxAppendSize(), nullValue()); + } + + @Test + public void testDiskStorageServiceSetAndGetMaxAppendSize() { + DiskStorageService storage = new DiskStorageService("/tmp"); + storage.setMaxAppendSize(0L); + assertThat(storage.getMaxAppendSize(), nullValue()); + + storage.setMaxAppendSize(-5L); + assertThat(storage.getMaxAppendSize(), nullValue()); + + storage.setMaxUploadSize(500L); + assertThat(storage.getMaxAppendSize(), is(500L)); + + storage.setMaxAppendSize(200L); + assertThat(storage.getMaxAppendSize(), is(200L)); + } +} diff --git a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java index 658f527f..7a05efc0 100644 --- a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java +++ b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java @@ -106,13 +106,15 @@ public void testSupportedHttpMethods() { "termination", "download", "expiration", - "concatenation")); + "concatenation", + "resumable-uploads-for-http")); } @Test public void testDisableFeature() throws Exception { tusFileUploadService.disableTusExtension("download"); tusFileUploadService.disableTusExtension("termination"); + tusFileUploadService.disableTusExtension("resumable-uploads-for-http"); assertThat( tusFileUploadService.getSupportedHttpMethods(), diff --git a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java index e3148a3d..9a154610 100644 --- a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java +++ b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java @@ -1,13 +1,17 @@ package me.desair.tus.server; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; import static org.mockito.Mockito.*; import java.io.IOException; import me.desair.tus.server.exception.UploadAlreadyLockedException; +import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLock; import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; import org.junit.Test; public class TusFileUploadServiceTest { @@ -163,4 +167,186 @@ public void testProcessLockFailure() throws Exception { verify(mockResp, times(1)).sendError(423, "Locked"); } + + @Test + public void testProtocolVersionConfiguration() { + TusFileUploadService service = new TusFileUploadService(); + assertThat( + service.getSupportedProtocolVersion(), org.hamcrest.CoreMatchers.is(ProtocolVersion.AUTO)); + + service.withSupportedProtocolVersions(ProtocolVersion.RUFH); + assertThat( + service.getSupportedProtocolVersion(), org.hamcrest.CoreMatchers.is(ProtocolVersion.RUFH)); + + service.withSupportedProtocolVersions(ProtocolVersion.TUS_1_0_0); + assertThat( + service.getSupportedProtocolVersion(), + org.hamcrest.CoreMatchers.is(ProtocolVersion.TUS_1_0_0)); + + service.withSupportedProtocolVersions(null); + assertThat( + service.getSupportedProtocolVersion(), + org.hamcrest.CoreMatchers.is(ProtocolVersion.TUS_1_0_0)); + } + + @Test + public void testDetectProtocolVersion() { + TusFileUploadService service = new TusFileUploadService(); + + // Forced TUS_1_0_0 + service.withSupportedProtocolVersions(ProtocolVersion.TUS_1_0_0); + assertThat( + service.detectProtocolVersion(null), + org.hamcrest.CoreMatchers.is(ProtocolVersion.TUS_1_0_0)); + + // Forced RUFH + service.withSupportedProtocolVersions(ProtocolVersion.RUFH); + assertThat( + service.detectProtocolVersion(null), org.hamcrest.CoreMatchers.is(ProtocolVersion.RUFH)); + + // AUTO mode + service.withSupportedProtocolVersions(ProtocolVersion.AUTO); + org.springframework.mock.web.MockHttpServletRequest req = + new org.springframework.mock.web.MockHttpServletRequest(); + assertThat( + service.detectProtocolVersion(req), + org.hamcrest.CoreMatchers.is(ProtocolVersion.TUS_1_0_0)); + + req.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + assertThat( + service.detectProtocolVersion(req), + org.hamcrest.CoreMatchers.is(ProtocolVersion.TUS_1_0_0)); + + req = new org.springframework.mock.web.MockHttpServletRequest(); + req.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + assertThat( + service.detectProtocolVersion(req), org.hamcrest.CoreMatchers.is(ProtocolVersion.RUFH)); + + req = new org.springframework.mock.web.MockHttpServletRequest(); + req.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + assertThat( + service.detectProtocolVersion(req), org.hamcrest.CoreMatchers.is(ProtocolVersion.RUFH)); + + req = new org.springframework.mock.web.MockHttpServletRequest(); + req.addHeader(HttpHeader.UPLOAD_DRAFT, "4"); + assertThat( + service.detectProtocolVersion(req), org.hamcrest.CoreMatchers.is(ProtocolVersion.RUFH)); + + req = new org.springframework.mock.web.MockHttpServletRequest(); + req.addHeader("upload-draft-interop-version", "4"); + assertThat( + service.detectProtocolVersion(req), org.hamcrest.CoreMatchers.is(ProtocolVersion.RUFH)); + } + + @Test + public void testWithMaxAppendSize() { + TusFileUploadService service = new TusFileUploadService(); + service.withMaxAppendSize(1024L); + assertThat(service.getUploadStorageService().getMaxAppendSize(), is(1024L)); + } + + @Test + public void testProtocolVersionGetName() { + assertThat(ProtocolVersion.TUS_1_0_0.getName(), is("TUS-1.0.0")); + assertThat(ProtocolVersion.RUFH.getName(), is("RUFH")); + assertThat(ProtocolVersion.AUTO.getName(), is("AUTO")); + } + + @Test + public void testProcessTusExceptionRufhOffsetMismatch() throws Exception { + UploadLockingService mockLockingService = mock(UploadLockingService.class); + UploadLock mockLock = mock(UploadLock.class); + when(mockLockingService.lockUploadByUri(anyString())).thenReturn(mockLock); + + UploadStorageService mockStorage = mock(UploadStorageService.class); + UploadInfo info = new UploadInfo(); + info.setOffset(100L); + when(mockStorage.getUploadInfo(anyString(), any())).thenReturn(info); + + org.springframework.mock.web.MockHttpServletRequest mockReq = + new org.springframework.mock.web.MockHttpServletRequest(); + org.springframework.mock.web.MockHttpServletResponse mockResp = + new org.springframework.mock.web.MockHttpServletResponse(); + + mockReq.setMethod("PATCH"); + mockReq.setRequestURI("/files/test"); + mockReq.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + mockReq.addHeader(HttpHeader.UPLOAD_OFFSET, "200"); + + TusFileUploadService service = + new TusFileUploadService() + .withUploadLockingService(mockLockingService) + .withUploadStorageService(mockStorage) + .withSupportedProtocolVersions(ProtocolVersion.RUFH); + + service.process(mockReq, mockResp, "owner"); + + assertThat(mockResp.getStatus(), is(409)); + assertThat( + mockResp.getHeader(HttpHeader.CONTENT_TYPE), is(HttpHeader.CONTENT_TYPE_PROBLEM_JSON)); + } + + @Test + public void testProcessTusExceptionRufhNullInfoAndHeader() throws Exception { + UploadLockingService mockLockingService = mock(UploadLockingService.class); + UploadLock mockLock = mock(UploadLock.class); + when(mockLockingService.lockUploadByUri(anyString())).thenReturn(mockLock); + + UploadStorageService mockStorage = mock(UploadStorageService.class); + UploadInfo info = new UploadInfo(); + // info with null offset + when(mockStorage.getUploadInfo(anyString(), any())).thenReturn(info); + + org.springframework.mock.web.MockHttpServletRequest mockReq = + new org.springframework.mock.web.MockHttpServletRequest(); + org.springframework.mock.web.MockHttpServletResponse mockResp = + new org.springframework.mock.web.MockHttpServletResponse(); + + mockReq.setMethod("PATCH"); + mockReq.setRequestURI("/files/test"); + mockReq.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + mockReq.addHeader(HttpHeader.UPLOAD_OFFSET, "200"); + + TusFileUploadService service = + new TusFileUploadService() + .withUploadLockingService(mockLockingService) + .withUploadStorageService(mockStorage) + .withSupportedProtocolVersions(ProtocolVersion.RUFH); + + service.process(mockReq, mockResp, "owner"); + + assertThat(mockResp.getStatus(), is(409)); + assertThat( + mockResp.getHeader(HttpHeader.CONTENT_TYPE), is(HttpHeader.CONTENT_TYPE_PROBLEM_JSON)); + } + + @Test + public void testProcessTusExceptionRufhNon409() throws Exception { + UploadLockingService mockLockingService = mock(UploadLockingService.class); + UploadLock mockLock = mock(UploadLock.class); + when(mockLockingService.lockUploadByUri(anyString())).thenReturn(mockLock); + + UploadStorageService mockStorage = mock(UploadStorageService.class); + UploadInfo info = new UploadInfo(); + when(mockStorage.getUploadInfo(anyString(), any())).thenReturn(info); + + org.springframework.mock.web.MockHttpServletRequest mockReq = + new org.springframework.mock.web.MockHttpServletRequest(); + org.springframework.mock.web.MockHttpServletResponse mockResp = + new org.springframework.mock.web.MockHttpServletResponse(); + + mockReq.setMethod("PATCH"); + mockReq.setRequestURI("/files/test"); + mockReq.addHeader(HttpHeader.CONTENT_TYPE, "text/plain"); + + TusFileUploadService service = + new TusFileUploadService() + .withUploadLockingService(mockLockingService) + .withUploadStorageService(mockStorage) + .withSupportedProtocolVersions(ProtocolVersion.RUFH); + + service.process(mockReq, mockResp, "owner"); + + assertThat(mockResp.getStatus(), is(415)); + } } diff --git a/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java index 5eed4834..f248e311 100644 --- a/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java @@ -167,13 +167,13 @@ public void processWithLockingService() throws Exception { .thenReturn(updatedInfo); UploadLockingService mockLocking = mock(UploadLockingService.class); - servletRequest.setAttribute("me.desair.tus.uploadLockingService", mockLocking); handler.process( HttpMethod.PATCH, new TusServletRequest(servletRequest), new TusServletResponse(servletResponse), uploadStorageService, + mockLocking, null); verify(mockLocking, times(1)) diff --git a/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java b/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java new file mode 100644 index 00000000..e49eb942 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java @@ -0,0 +1,114 @@ +package me.desair.tus.server.rufh; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletResponse; + +public class HttpProblemDetailsTest { + + private MockHttpServletResponse response; + + @Before + public void setUp() { + response = new MockHttpServletResponse(); + } + + /** + * Section 7.1 (Mismatching Offset) of draft-ietf-httpbis-resumable-upload-11 & RFC 7807: "This + * section defines the 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset' + * problem type. A server can use this problem type when responding to an upload append request + * (Section 4.4) to indicate that the Upload-Offset header field in the request does not match the + * upload resource's offset." + */ + @Test + public void testOffsetMismatchProblemDetailsObject() throws Exception { + HttpProblemDetails problem = HttpProblemDetails.forOffsetMismatch(12500000L, 25000000L); + + assertThat(problem.getStatus(), is(409)); + assertThat( + problem.getType(), + is("https://iana.org/assignments/http-problem-types#mismatching-upload-offset")); + assertThat(problem.getTitle(), is("Offset Mismatch")); + assertThat( + problem.getDetail(), + is("The provided Upload-Offset does not match the server's current offset")); + assertThat(problem.getExtraFields().get("expected-offset"), is(12500000L)); + + problem.writeTo(new TusServletResponse(response)); + + assertThat(response.getStatus(), is(409)); + assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); + assertThat( + response.getContentAsString(), + is( + "{\"type\":\"https://iana.org/assignments/http-problem-types#mismatching-upload-offset\"," + + "\"title\":\"Offset Mismatch\"," + + "\"status\":409," + + "\"detail\":\"The provided Upload-Offset does not match the server's current offset\"," + + "\"expected-offset\":12500000}")); + } + + /** + * Section 7.2 (Completed Upload) of draft-ietf-httpbis-resumable-upload-11 & RFC 7807: "This + * section defines the 'https://iana.org/assignments/http-problem-types#completed-upload' problem + * type. A server can use this problem type when responding to an upload append request (Section + * 4.4) to indicate that the upload has already been completed and cannot be modified." + */ + @Test + public void testCompletedUploadProblemDetailsObject() throws Exception { + HttpProblemDetails problem = HttpProblemDetails.forCompletedUpload(400); + + assertThat(problem.getStatus(), is(400)); + assertThat( + problem.getType(), is("https://iana.org/assignments/http-problem-types#completed-upload")); + assertThat(problem.getTitle(), is("Upload Completed")); + + problem.writeTo(new TusServletResponse(response)); + + assertThat(response.getStatus(), is(400)); + assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); + assertThat( + response.getContentAsString(), + is( + "{\"type\":\"https://iana.org/assignments/http-problem-types#completed-upload\"," + + "\"title\":\"Upload Completed\"," + + "\"status\":400," + + "\"detail\":\"The upload resource is already completed and cannot be modified\"}")); + } + + /** + * Section 7.3 (Inconsistent Length) of draft-ietf-httpbis-resumable-upload-11 & RFC 7807: "This + * section defines the + * 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length' problem type. A + * server can use this problem type when responding to an upload creation (Section 4.2) or upload + * append request (Section 4.4) to indicate that the request includes inconsistent upload length + * values, as described in Section 4.1.3." + */ + @Test + public void testInconsistentLengthProblemDetailsObject() throws Exception { + HttpProblemDetails problem = HttpProblemDetails.forInconsistentLength(); + + assertThat(problem.getStatus(), is(400)); + assertThat( + problem.getType(), + is("https://iana.org/assignments/http-problem-types#inconsistent-upload-length")); + assertThat(problem.getTitle(), is("Inconsistent Upload Length")); + + problem.writeTo(new TusServletResponse(response)); + + assertThat(response.getStatus(), is(400)); + assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); + assertThat( + response.getContentAsString(), + is( + "{\"type\":\"https://iana.org/assignments/http-problem-types#inconsistent-upload-length\"," + + "\"title\":\"Inconsistent Upload Length\"," + + "\"status\":400," + + "\"detail\":\"The provided Upload-Length does not match existing metadata\"}")); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java new file mode 100644 index 00000000..f134f062 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java @@ -0,0 +1,370 @@ +package me.desair.tus.server.rufh; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.InterruptibleInputStream; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhProtocolAppendTest { + + private ResumableUploadsForHttpProtocol protocol; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + @Mock private UploadLockingService lockingService; + + @Before + public void setUp() { + protocol = new ResumableUploadsForHttpProtocol(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + + when(storageService.getUploadUri()).thenReturn("/files"); + } + + /** + * Section 5.1 (Upload Append - Client Behavior): "A client can continue the upload and append + * representation data by sending a PATCH request with the application/partial-upload media type + * to the upload resource. The request MUST indicate the offset of the request content inside the + * representation data by including the Upload-Offset header field." + * + *

Section 5.2 (Upload Append - Server Behavior): "If the Upload-Complete request header field + * is set to false... the upload resource acknowledges the appended data by sending a 2xx response + * with the Upload-Complete header field set to false." + */ + @Test + public void testUploadAppendPartialDataSuccess() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + request.setContent("chunk content".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(1000L); + info.setLength(5000L); + + UploadInfo updated = new UploadInfo(); + updated.setId(new UploadId("test-id")); + updated.setOffset(1013L); + updated.setLength(5000L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(updated); + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.PATCH, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1013")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); + } + + /** + * Section 4.4.2 (Server Behavior - Upload Append): "If the length is known, the server MUST + * prevent the offset from exceeding the upload length by rejecting the request once the offset + * exceeds the length, marking the upload resource invalid and rejecting any further interaction + * with it." + */ + @Test(expected = TusException.class) + public void testAppendExceedingUploadLengthRejected() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "4990"); + request.setContent("This content is 30 bytes long".getBytes()); // 4990 + 30 = 5020 > 5000 + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(4990L); + info.setLength(5000L); // Declared length is 5000 + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } + + /** + * Section 7.2 (Completed Upload) of draft-11: "This section defines the + * 'https://iana.org/assignments/http-problem-types#completed-upload' problem type. A server can + * use this problem type when responding to an upload append request (Section 4.4) to indicate + * that the upload has already been completed and cannot be modified." + */ + @Test + public void testCompletedUploadFormatsProblemJson() throws Exception { + MockHttpServletResponse errResponse = new MockHttpServletResponse(); + errResponse.setStatus(400); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(5000L); + info.setLength(5000L); // Completed + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + request.setRequestURI("/files/test-id"); + TusServletRequest tusReq = new TusServletRequest(request, true); + + protocol.handleError( + HttpMethod.PATCH, + tusReq, + new TusServletResponse(errResponse), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(errResponse.getStatus(), is(400)); + assertThat(errResponse.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); + assertThat( + errResponse.getContentAsString(), + is( + "{\"type\":\"https://iana.org/assignments/http-problem-types#completed-upload\"," + + "\"title\":\"Upload Completed\"," + + "\"status\":400," + + "\"detail\":\"The upload resource is already completed and cannot be modified\"}")); + } + + /** + * Appendix B (Draft Version Identification) of draft-11: "Server implementations of draft + * versions of the protocol send a header field Upload-Draft with the interop version." + */ + @Test + public void testUploadDraftHeaderInPatchResponse() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + request.setContent("data".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(1000L); + + UploadInfo updated = new UploadInfo(); + updated.setId(new UploadId("test-id")); + updated.setOffset(1004L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(updated); + + protocol.process( + HttpMethod.PATCH, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); + } + + /** + * Section 5.1 & 5.2 (Complete Upload Append): "If the Upload-Complete request header field is set + * to true... the server completes the upload and acknowledges the final request with a 200 OK + * status code." + */ + @Test + public void testUploadAppendCompleteSuccess() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + request.setContent("final chunk".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(1000L); + + UploadInfo updated = new UploadInfo(); + updated.setId(new UploadId("test-id")); + updated.setOffset(1011L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(updated); + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.PATCH, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(200)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1011")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + } + + /** + * Section 5.2 (Server Behavior - Offset Mismatch): "If the Upload-Offset request header field + * value does not match the current offset... the upload resource MUST reject the request with a + * 409 (Conflict) status code." + */ + @Test(expected = TusException.class) + public void testUploadAppendMismatchingOffsetThrows409() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "2000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(1000L); // Actual offset is 1000 + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } + + /** + * Section 5.2 (Server Behavior - Completed Upload Reject): "If the upload is already complete... + * the server MUST NOT modify the upload resource and MUST reject the request." + */ + @Test(expected = TusException.class) + public void testUploadAppendToAlreadyCompletedUploadRejected() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "5000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(5000L); + info.setLength(5000L); // Completed + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } + + /** + * Section 5.1 (Upload Append - Payload Limits): "The server MAY enforce limits on the size of + * individual data append requests." + */ + @Test(expected = TusException.class) + public void testUploadAppendExceedingMaxAppendSizeThrows413() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.setContent("This append payload is 32 bytes long".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(1000L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + when(storageService.getMaxAppendSize()).thenReturn(10L); // Max append limit 10 bytes + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } + + /** + * Section 5.1 (Upload Append - Payload Limits): "Data append requests within the maximum size + * limit are accepted." + */ + @Test + public void testUploadAppendWithinMaxAppendSizeSuccess() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.setContent("short payload".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(1000L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + when(storageService.getMaxAppendSize()).thenReturn(100L); // Max append limit 100 bytes + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } + + /** + * Section 5.2 (Upload Append - Streaming & Lock Registration): Tests that during PATCH append + * streaming, the input stream is wrapped in an InterruptibleInputStream and registered with the + * UploadLockingService to support lock contention resolution. + */ + @Test + public void testUploadAppendRegistersInterruptibleStreamWithLockingService() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + request.setContent("chunk content".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(1000L); + info.setLength(5000L); + + UploadInfo updated = new UploadInfo(); + updated.setId(new UploadId("test-id")); + updated.setOffset(1013L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(updated); + + protocol.process( + HttpMethod.PATCH, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + verify(lockingService) + .registerInputStream(eq("/files/test-id"), any(InterruptibleInputStream.class)); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java new file mode 100644 index 00000000..6ef138af --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java @@ -0,0 +1,70 @@ +package me.desair.tus.server.rufh; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhProtocolCancellationTest { + + private ResumableUploadsForHttpProtocol protocol; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + protocol = new ResumableUploadsForHttpProtocol(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + + when(storageService.getUploadUri()).thenReturn("/files"); + } + + /** + * Section 7 (Upload Cancellation): "If the client determines that it no longer needs the upload + * resource, it CAN request its deletion by sending a DELETE request to the upload resource URL... + * The server MUST acknowledge a successful upload cancellation with a 2xx status code." + */ + @Test + public void testUploadCancellationSuccess() throws Exception { + request.setMethod("DELETE"); + request.setRequestURI("/files/test-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(500L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + protocol.validate(HttpMethod.DELETE, request, storageService, null, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.DELETE, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + null, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(204)); + verify(storageService).terminateUpload(info); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java new file mode 100644 index 00000000..86483812 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java @@ -0,0 +1,238 @@ +package me.desair.tus.server.rufh; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.upload.UuidUploadIdFactory; +import me.desair.tus.server.util.InterruptibleInputStream; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhProtocolCreationTest { + + private ResumableUploadsForHttpProtocol protocol; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + @Mock private UploadLockingService lockingService; + + @Before + public void setUp() { + protocol = new ResumableUploadsForHttpProtocol(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + + when(storageService.getUploadUri()).thenReturn("/files"); + } + + /** + * Section 4.2.1 (Upload Creation - Client Behavior): "A client can start a resumable upload from + * any request that can carry content by including the Upload-Complete header field. If the client + * knows the representation data's length, it SHOULD indicate the length in the request through + * the Upload-Length header field." + * + *

Section 4.2.2 (Upload Creation - Server Behavior): "If the Upload-Complete header field is + * set to false, the client intends to transfer the representation over multiple requests. If the + * request content was fully received, the server MUST include the Location response header field + * pointing to the upload resource... Servers are RECOMMENDED to use the 201 (Created) status + * code." + */ + @Test + public void testUploadCreationPartialWithLength() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "10000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setLength(10000L); + info.setOffset(0L); + info.setId(new UuidUploadIdFactory().createId()); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + protocol.validate( + HttpMethod.POST, request, storageService, lockingService, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.POST, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(201)); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/" + info.getId())); + } + + /** + * Section 4.2.1 & 4.2.2 (Complete Upload Creation): "If the Upload-Complete request header field + * is set to true, the client intends to transfer the entire representation data in one request. + * If the request content was fully received, no resumable upload is needed and the resource + * proceeds to process the request and generate a response." + */ + @Test + public void testUploadCreationCompleteSingleRequest() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "11"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + request.setContent("Hello World".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setLength(11L); + info.setOffset(11L); + info.setId(new UuidUploadIdFactory().createId()); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + protocol.validate( + HttpMethod.POST, request, storageService, lockingService, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.POST, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(200)); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); + } + + /** + * Section 4.1.3 (Length) & Section 7.3 (Inconsistent Length): "If indicators (1) + * [Upload-Complete: ?1 with Content-Length] and (2) [Upload-Length] are both present in the same + * request, their indicated lengths MUST match. If multiple requests include indicators, their + * indicated values MUST match. A server can use the problem type of + * 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length' in responses to + * indicate inconsistent length values." + */ + @Test(expected = me.desair.tus.server.exception.TusException.class) + public void testInconsistentUploadLengthValidation() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + request.setContent("Hello World".getBytes()); // 11 bytes != 1000 bytes + + protocol.validate( + HttpMethod.POST, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } + + /** + * Section 4.2.1 & 4.2.2 (Upload Creation without Upload-Length): "If the Upload-Complete header + * field is set to true, but Upload-Length is omitted, the server determines the length from the + * content sent." + */ + @Test + public void testUploadCreationCompleteWithoutUploadLength() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + request.setContent("Hello World".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setOffset(0L); + info.setId(new UuidUploadIdFactory().createId()); + + UploadInfo appended = new UploadInfo(); + appended.setOffset(11L); + appended.setId(info.getId()); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(appended); + + protocol.validate( + HttpMethod.POST, request, storageService, lockingService, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.POST, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(200)); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("11")); + } + + /** + * Section 4.1.4 (Limits): "The server might not create an upload resource if the length deduced + * from the upload creation request is larger than the maximum size." + */ + @Test(expected = me.desair.tus.server.exception.TusException.class) + public void testUploadCreationExceedingMaxSize() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "200000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + when(storageService.getMaxUploadSize()).thenReturn(100000L); + + protocol.validate( + HttpMethod.POST, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } + + /** + * Section 4.2.2 (Upload Creation - Streaming & Lock Registration): Tests that when creating an + * upload with body content, the input stream is wrapped in an InterruptibleInputStream and + * registered with the UploadLockingService to support lock contention resolution. + */ + @Test + public void testUploadCreationRegistersInterruptibleStreamWithLockingService() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + request.setContent("Stream data".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setLength(1000L); + info.setOffset(0L); + info.setId(new UuidUploadIdFactory().createId()); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + protocol.process( + HttpMethod.POST, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + verify(lockingService) + .registerInputStream(eq("/files/" + info.getId()), any(InterruptibleInputStream.class)); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolHeadTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolHeadTest.java new file mode 100644 index 00000000..dd2db330 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolHeadTest.java @@ -0,0 +1,146 @@ +package me.desair.tus.server.rufh; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhProtocolHeadTest { + + private ResumableUploadsForHttpProtocol protocol; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + protocol = new ResumableUploadsForHttpProtocol(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + + when(storageService.getUploadUri()).thenReturn("/files"); + } + + /** + * Section 4.3.2 (Server Behavior - Offset Retrieval) of draft-11: "A successful response to a + * HEAD request against an upload resource MUST include the offset in the Upload-Offset header + * field, MUST include the completeness state in the Upload-Complete header field, MUST include + * the length in the Upload-Length header field (unless omitted), MUST indicate limits in the + * Upload-Limit header field, and SHOULD include the Cache-Control header field with value + * no-store." + */ + @Test + public void testHeadOffsetRetrievalIncompleteUpload() throws Exception { + request.setMethod("HEAD"); + request.setRequestURI("/files/test-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(2500L); + info.setLength(10000L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + when(storageService.getMaxUploadSize()).thenReturn(500000L); + + protocol.validate(HttpMethod.HEAD, request, storageService, null, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.HEAD, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + null, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("2500")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + assertThat(response.getHeader(HttpHeader.UPLOAD_LENGTH), is("10000")); + assertThat(response.getHeader(HttpHeader.CACHE_CONTROL), is("no-store")); + assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=500000")); + } + + /** + * Section 6.2 (Status Response for Completed Upload): "HEAD /upload/a9edb781b HTTP/1.1 HTTP/1.1 + * 204 No Content Upload-Complete: ?1 Upload-Offset: 100000000 Upload-Length: 100000000 + * Cache-Control: no-store" + */ + @Test + public void testHeadOffsetRetrievalCompletedUpload() throws Exception { + request.setMethod("HEAD"); + request.setRequestURI("/files/test-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(10000L); + info.setLength(10000L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + protocol.validate(HttpMethod.HEAD, request, storageService, null, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.HEAD, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + null, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("10000")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + } + + /** + * Section 6.1 (Status Request): "The request MUST NOT contain Upload-Offset or Upload-Complete + * header fields." + */ + @Test(expected = me.desair.tus.server.exception.TusException.class) + public void testHeadWithForbiddenUploadOffsetHeader() throws Exception { + request.setMethod("HEAD"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "100"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + protocol.validate(HttpMethod.HEAD, request, storageService, null, null, ProtocolVersion.RUFH); + } + + /** + * Section 6.1 (Status Request): "The request MUST NOT contain Upload-Offset or Upload-Complete + * header fields." + */ + @Test(expected = me.desair.tus.server.exception.TusException.class) + public void testHeadWithForbiddenUploadCompleteHeader() throws Exception { + request.setMethod("HEAD"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + protocol.validate(HttpMethod.HEAD, request, storageService, null, null, ProtocolVersion.RUFH); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java new file mode 100644 index 00000000..957114c1 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java @@ -0,0 +1,94 @@ +package me.desair.tus.server.rufh.handler; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.InterruptibleInputStream; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhAppendPatchRequestHandlerTest { + + private RufhAppendPatchRequestHandler handler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + @Mock private UploadLockingService lockingService; + + @Before + public void setUp() { + handler = new RufhAppendPatchRequestHandler(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + public void testSupports() { + assertTrue(handler.supports(HttpMethod.PATCH)); + assertFalse(handler.supports(HttpMethod.POST)); + } + + /** + * Section 5.2 (Upload Append - Server Behavior): "If the Upload-Complete request header field is + * set to false... the upload resource acknowledges the appended data by sending a 2xx response + * with the Upload-Complete header field set to false." + */ + @Test + public void testProcessPartialAppend() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/append-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + request.setContent("append data".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("append-id")); + info.setOffset(1000L); + info.setLength(5000L); + + UploadInfo updated = new UploadInfo(); + updated.setId(info.getId()); + updated.setOffset(1011L); + updated.setLength(5000L); + + when(storageService.getUploadInfo("/files/append-id", "owner")).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(updated); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1011")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + + verify(lockingService) + .registerInputStream(eq("/files/append-id"), any(InterruptibleInputStream.class)); + } +} 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 new file mode 100644 index 00000000..d73823a4 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java @@ -0,0 +1,120 @@ +package me.desair.tus.server.rufh.handler; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.InterruptibleInputStream; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhCreationPostRequestHandlerTest { + + private RufhCreationPostRequestHandler handler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + @Mock private UploadLockingService lockingService; + + @Before + public void setUp() { + handler = new RufhCreationPostRequestHandler(null); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + + when(storageService.getUploadUri()).thenReturn("/files"); + } + + @Test + public void testSupports() { + assertTrue(handler.supports(HttpMethod.POST)); + assertTrue(handler.supports(HttpMethod.PUT)); + assertTrue(handler.supports(HttpMethod.PATCH)); + assertFalse(handler.supports(HttpMethod.GET)); + } + + /** + * Section 4.2.2 (Upload Creation - Server Behavior): "If the Upload-Complete header field is set + * to false... the server MUST include the Location response header field pointing to the upload + * resource... Servers are RECOMMENDED to use the 201 (Created) status code." + */ + @Test + public void testProcessPartialUploadCreation() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setLength(5000L); + info.setOffset(0L); + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + assertThat(response.getStatus(), is(201)); + assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("0")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + } + + /** + * Section 4.2.2 (Upload Creation - Streaming & Lock Registration): Tests that the creation + * request input stream is wrapped and registered for lock contention resolution. + */ + @Test + public void testProcessCreationRegistersInputStream() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + request.setContent("creation body".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setLength(1000L); + info.setOffset(0L); + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + verify(lockingService) + .registerInputStream(eq("/files/creation-id"), any(InterruptibleInputStream.class)); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java new file mode 100644 index 00000000..f546b692 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java @@ -0,0 +1,70 @@ +package me.desair.tus.server.rufh.handler; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhDeleteRequestHandlerTest { + + private RufhDeleteRequestHandler handler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + handler = new RufhDeleteRequestHandler(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + public void testSupports() { + assertTrue(handler.supports(HttpMethod.DELETE)); + assertFalse(handler.supports(HttpMethod.POST)); + } + + /** + * Section 7 (Upload Cancellation): "If the client determines that it no longer needs the upload + * resource, it CAN request its deletion by sending a DELETE request to the upload resource URL... + * The server MUST acknowledge a successful upload cancellation with a 2xx status code." + */ + @Test + public void testProcessDeleteRequest() throws Exception { + request.setRequestURI("/files/delete-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("delete-id")); + when(storageService.getUploadInfo("/files/delete-id", "owner")).thenReturn(info); + + handler.process( + HttpMethod.DELETE, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(204)); + verify(storageService).terminateUpload(info); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java new file mode 100644 index 00000000..acc7d69a --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java @@ -0,0 +1,80 @@ +package me.desair.tus.server.rufh.handler; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhErrorHandlerTest { + + private RufhErrorHandler handler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + handler = new RufhErrorHandler(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + public void testSupports() { + assertTrue(handler.supports(HttpMethod.PATCH)); + assertTrue(handler.supports(HttpMethod.POST)); + } + + @Test + public void testIsErrorHandler() { + assertTrue(handler.isErrorHandler()); + } + + /** + * Section 8 (Offset Mismatch): "If the Upload-Offset request header field value does not match + * the current offset... the server MUST respond with a 409 Conflict status code and an + * application/problem+json response body including the expected-offset member." + */ + @Test + public void testProcessErrorHandler409Mismatch() throws Exception { + request.setRequestURI("/files/mismatch-id"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "2000"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("mismatch-id")); + info.setOffset(1000L); // Server offset is 1000 + when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(info); + + response.setStatus(409); // Mismatch set by validation exception handling flow + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(409)); + assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); + assertThat(response.getContentAsString(), containsString("\"expected-offset\":1000")); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java new file mode 100644 index 00000000..927d154f --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java @@ -0,0 +1,105 @@ +package me.desair.tus.server.rufh.handler; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhHeadRequestHandlerTest { + + private RufhHeadRequestHandler handler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + handler = new RufhHeadRequestHandler(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + public void testSupports() { + assertTrue(handler.supports(HttpMethod.HEAD)); + assertFalse(handler.supports(HttpMethod.POST)); + } + + /** + * Section 6.2 (Status Response): "A successful response to a HEAD request against an upload + * resource MUST include the offset in the Upload-Offset header field, MUST include the + * completeness state in the Upload-Complete header field, and SHOULD include the Cache-Control + * header field with value no-store." + */ + @Test + public void testProcessIncompleteHeadRequest() throws Exception { + request.setRequestURI("/files/incomplete-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("incomplete-id")); + info.setOffset(2500L); + info.setLength(10000L); + when(storageService.getUploadInfo("/files/incomplete-id", "owner")).thenReturn(info); + when(storageService.getMaxUploadSize()).thenReturn(500000L); + + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("2500")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + assertThat(response.getHeader(HttpHeader.UPLOAD_LENGTH), is("10000")); + assertThat(response.getHeader(HttpHeader.CACHE_CONTROL), is("no-store")); + assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=500000")); + } + + /** + * Section 6.2 (Status Response for Completed Upload): "HEAD /upload/a9edb781b HTTP/1.1 HTTP/1.1 + * 204 No Content Upload-Complete: ?1 Upload-Offset: 100000000" + */ + @Test + public void testProcessCompletedHeadRequest() throws Exception { + request.setRequestURI("/files/completed-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("completed-id")); + info.setOffset(10000L); + info.setLength(10000L); // Completed + when(storageService.getUploadInfo("/files/completed-id", "owner")).thenReturn(info); + + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("10000")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java new file mode 100644 index 00000000..8dbee5a8 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java @@ -0,0 +1,70 @@ +package me.desair.tus.server.rufh.handler; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhOptionsRequestHandlerTest { + + private RufhOptionsRequestHandler handler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + handler = new RufhOptionsRequestHandler(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + public void testSupports() { + assertTrue(handler.supports(HttpMethod.OPTIONS)); + assertFalse(handler.supports(HttpMethod.POST)); + } + + /** + * Section 3 (Feature Discovery): "If the server supports resumable uploads, it MUST respond to an + * OPTIONS request with Accept-Patch containing application/partial-upload, and Upload-Draft + * containing the draft version." + */ + @Test + public void testProcessOptionsRequest() throws Exception { + when(storageService.getMaxUploadSize()).thenReturn(100000L); + when(storageService.getMaxAppendSize()).thenReturn(50000L); + + handler.process( + HttpMethod.OPTIONS, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat( + response.getHeader(HttpHeader.ACCEPT_PATCH), + is("application/partial-upload, application/offset+octet-stream")); + assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); + assertThat( + response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=100000, max-append-size=50000")); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/security/RufhSecurityTest.java b/src/test/java/me/desair/tus/server/rufh/security/RufhSecurityTest.java new file mode 100644 index 00000000..dd7ccb5c --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/security/RufhSecurityTest.java @@ -0,0 +1,128 @@ +package me.desair.tus.server.rufh.security; + +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.ResumableUploadsForHttpProtocol; +import me.desair.tus.server.upload.UploadStorageService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * Security unit tests verifying path traversal prevention in RUFH requests. + * + *

Reference: Section 10 (Security Considerations) of draft-ietf-httpbis-resumable-upload. + */ +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhSecurityTest { + + private ResumableUploadsForHttpProtocol protocol; + private MockHttpServletRequest request; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + protocol = new ResumableUploadsForHttpProtocol(); + request = new MockHttpServletRequest(); + } + + /** + * Section 10 (Security Considerations): "Servers MUST prevent path traversal attacks in upload + * URIs." + */ + @Test(expected = TusException.class) + public void testPathTraversalWithDotDotInUri() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files/../secret"); + + protocol.validate(HttpMethod.POST, request, storageService, null, null, ProtocolVersion.RUFH); + } + + /** + * Section 10 (Security Considerations): "Servers MUST reject null bytes in upload request paths." + */ + @Test(expected = TusException.class) + public void testPathTraversalWithNullByteInUri() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files/test\0file"); + + protocol.validate(HttpMethod.POST, request, storageService, null, null, ProtocolVersion.RUFH); + } + + /** + * Section 10 (Security Considerations): "HEAD requests containing path traversal sequences MUST + * be rejected." + */ + @Test(expected = TusException.class) + public void testPathTraversalWithDotDotInHead() throws Exception { + request.setMethod("HEAD"); + request.setRequestURI("/files/../../etc/passwd"); + + protocol.validate(HttpMethod.HEAD, request, storageService, null, null, ProtocolVersion.RUFH); + } + + /** + * Section 10 (Security Considerations): "PATCH requests containing path traversal sequences MUST + * be rejected." + */ + @Test(expected = TusException.class) + public void testPathTraversalWithDotDotInPatch() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/../config"); + + protocol.validate(HttpMethod.PATCH, request, storageService, null, null, ProtocolVersion.RUFH); + } + + /** + * Section 13 (Security Considerations) of draft-11: "DELETE requests containing path traversal + * sequences MUST be rejected." + */ + @Test(expected = TusException.class) + public void testPathTraversalWithDotDotInDelete() throws Exception { + request.setMethod("DELETE"); + request.setRequestURI("/files/../system"); + + protocol.validate(HttpMethod.DELETE, request, storageService, null, null, ProtocolVersion.RUFH); + } + + /** + * Section 13 (Security Considerations) of draft-11: "To reduce the risk of unauthorized access, + * it is RECOMMENDED to generate upload resource URIs in such a way that makes it hard to be + * guessed... The server SHOULD ensure that only authorized clients can access the upload + * resource." + */ + @Test(expected = TusException.class) + public void testCrossTenantAccessPrevented() throws Exception { + request.setMethod("HEAD"); + request.setRequestURI("/files/user1-upload"); + + // storageService returns null when accessed with an unauthorized owner key ("user2") + org.mockito.Mockito.when(storageService.getUploadInfo("/files/user1-upload", "user2")) + .thenReturn(null); + + protocol.validate( + HttpMethod.HEAD, request, storageService, null, "user2", ProtocolVersion.RUFH); + } + + /** + * Section 13 (Security Considerations) of draft-11: "Servers or intermediaries need to consider + * that relying solely on message content limits to constrain resources allocated to uploads might + * not be an effective strategy... Servers SHOULD provide mitigations for Slowloris attacks..." + */ + @Test(expected = TusException.class) + public void testMaxAppendSizeEnforcedInCreationAndAppend() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.setContent("Oversized payload content".getBytes()); + + org.mockito.Mockito.when(storageService.getMaxAppendSize()) + .thenReturn(5L); // 5 byte max append limit + + protocol.validate(HttpMethod.POST, request, storageService, null, null, ProtocolVersion.RUFH); + } +} 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 new file mode 100644 index 00000000..39eb4a21 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java @@ -0,0 +1,106 @@ +package me.desair.tus.server.rufh.validation; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhAppendValidatorTest { + + private RufhAppendValidator validator; + private MockHttpServletRequest request; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + validator = new RufhAppendValidator(); + request = new MockHttpServletRequest(); + } + + @Test + public void testSupports() { + assertTrue(validator.supports(HttpMethod.PATCH)); + assertFalse(validator.supports(HttpMethod.POST)); + } + + /** + * Section 5.1 (Append Request): "The request MUST indicate the offset of the request content + * inside the representation data by including the Upload-Offset header field." + */ + @Test(expected = TusException.class) + public void testValidateMissingUploadOffsetHeader() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + /** + * Section 5.2 (Server Behavior - Offset Mismatch): "If the Upload-Offset request header field + * value does not match the current offset... the upload resource MUST reject the request with a + * 409 (Conflict) status code." + */ + @Test(expected = TusException.class) + public void testValidateMismatchingUploadOffsetHeader() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "2000"); // Current offset is 1000 + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + /** + * Section 5.2 (Server Behavior - Completed Upload Reject): "If the upload is already complete... + * the server MUST NOT modify the upload resource and MUST reject the request." + */ + @Test(expected = TusException.class) + public void testValidateCompletedUploadRejected() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "5000"); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(5000L); // Completed + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test + public void testValidateValidAppendRequest() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java new file mode 100644 index 00000000..423f7c92 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java @@ -0,0 +1,72 @@ +package me.desair.tus.server.rufh.validation; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhCreationValidatorTest { + + private RufhCreationValidator validator; + private MockHttpServletRequest request; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + validator = new RufhCreationValidator(); + request = new MockHttpServletRequest(); + } + + @Test + public void testSupports() { + assertTrue(validator.supports(HttpMethod.POST)); + assertTrue(validator.supports(HttpMethod.PUT)); + assertFalse(validator.supports(HttpMethod.GET)); + } + + /** + * Section 4.1.4 (Limits): "The server might not create an upload resource if the length deduced + * from the upload creation request is larger than the maximum size." + */ + @Test(expected = TusException.class) + public void testValidateUploadLengthExceedsMaxLimit() throws Exception { + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000000"); + when(storageService.getMaxUploadSize()).thenReturn(500000L); + + validator.validate(HttpMethod.POST, request, storageService, null); + } + + /** + * Section 4.1.4 (Limits): "The server MAY enforce limits on the size of individual data append + * requests." + */ + @Test(expected = TusException.class) + public void testValidateContentLengthExceedsMaxAppendLimit() throws Exception { + request.setContent("This payload is long".getBytes()); + when(storageService.getMaxAppendSize()).thenReturn(5L); + + validator.validate(HttpMethod.POST, request, storageService, null); + } + + @Test + public void testValidateAcceptableLimits() throws Exception { + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + request.setContent("valid".getBytes()); + when(storageService.getMaxUploadSize()).thenReturn(5000L); + when(storageService.getMaxAppendSize()).thenReturn(5000L); + + validator.validate(HttpMethod.POST, request, storageService, null); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidatorTest.java new file mode 100644 index 00000000..0e110e3b --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidatorTest.java @@ -0,0 +1,54 @@ +package me.desair.tus.server.rufh.validation; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class RufhHeadHeaderValidatorTest { + + private RufhHeadHeaderValidator validator; + private MockHttpServletRequest request; + + @Before + public void setUp() { + validator = new RufhHeadHeaderValidator(); + request = new MockHttpServletRequest(); + } + + @Test + public void testSupports() { + assertTrue(validator.supports(HttpMethod.HEAD)); + assertFalse(validator.supports(HttpMethod.POST)); + } + + /** + * Section 6.1 (Status Request): "The request MUST NOT contain Upload-Offset or Upload-Complete + * header fields." + */ + @Test(expected = TusException.class) + public void testValidateWithUploadOffsetHeader() throws Exception { + request.addHeader(HttpHeader.UPLOAD_OFFSET, "100"); + validator.validate(HttpMethod.HEAD, request, null, null); + } + + /** + * Section 6.1 (Status Request): "The request MUST NOT contain Upload-Offset or Upload-Complete + * header fields." + */ + @Test(expected = TusException.class) + public void testValidateWithUploadCompleteHeader() throws Exception { + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + validator.validate(HttpMethod.HEAD, request, null, null); + } + + @Test + public void testValidateCleanHeadRequest() throws Exception { + validator.validate(HttpMethod.HEAD, request, null, null); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java new file mode 100644 index 00000000..f9928bf1 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java @@ -0,0 +1,54 @@ +package me.desair.tus.server.rufh.validation; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class RufhSafePathValidatorTest { + + private RufhSafePathValidator validator; + private MockHttpServletRequest request; + + @Before + public void setUp() { + validator = new RufhSafePathValidator(); + request = new MockHttpServletRequest(); + } + + @Test + public void testSupports() { + assertTrue(validator.supports(HttpMethod.POST)); + assertTrue(validator.supports(HttpMethod.GET)); + assertFalse(validator.supports(null)); + } + + /** + * Section 10 (Security Considerations): "Servers MUST prevent path traversal attacks in upload + * URIs." + */ + @Test(expected = TusException.class) + public void testValidatePathTraversalDotDot() throws Exception { + request.setRequestURI("/files/../etc/passwd"); + validator.validate(HttpMethod.POST, request, null, null); + } + + /** + * Section 10 (Security Considerations): "Servers MUST reject null bytes in upload request paths." + */ + @Test(expected = TusException.class) + public void testValidatePathTraversalNullByte() throws Exception { + request.setRequestURI("/files/test\0file"); + validator.validate(HttpMethod.POST, request, null, null); + } + + @Test + public void testValidateSafePath() throws Exception { + request.setRequestURI("/files/valid-upload-id"); + validator.validate(HttpMethod.POST, request, null, null); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidatorTest.java new file mode 100644 index 00000000..df767bbc --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidatorTest.java @@ -0,0 +1,58 @@ +package me.desair.tus.server.rufh.validation; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class RufhUploadExistsValidatorTest { + + private RufhUploadExistsValidator validator; + private MockHttpServletRequest request; + + @Mock private UploadStorageService storageService; + + @Before + public void setUp() { + validator = new RufhUploadExistsValidator(); + request = new MockHttpServletRequest(); + } + + @Test + public void testSupports() { + assertTrue(validator.supports(HttpMethod.HEAD)); + assertTrue(validator.supports(HttpMethod.DELETE)); + assertFalse(validator.supports(HttpMethod.POST)); + } + + /** + * Section 6.1 (Status Request) & Section 7 (Upload Cancellation): "If the upload resource does + * not exist, the server MUST reject the request with a 404 (Not Found) status code." + */ + @Test(expected = TusException.class) + public void testValidateUploadDoesNotExist() throws Exception { + request.setRequestURI("/files/non-existent-id"); + when(storageService.getUploadInfo("/files/non-existent-id", "owner")).thenReturn(null); + + validator.validate(HttpMethod.HEAD, request, storageService, "owner"); + } + + @Test + public void testValidateUploadExists() throws Exception { + request.setRequestURI("/files/exists-id"); + when(storageService.getUploadInfo("/files/exists-id", "owner")).thenReturn(new UploadInfo()); + + validator.validate(HttpMethod.HEAD, request, storageService, "owner"); + } +} 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 26c42278..51190565 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 @@ -786,4 +786,16 @@ public void testTerminateUploadWithUnsafeChecksums() throws Exception { storageService.terminateUpload(info); }); } + + @Test + public void testMaxAppendSizeDefaulting() { + DiskStorageService diskService = new DiskStorageService(storagePath.toString()); + assertThat(diskService.getMaxAppendSize(), is(nullValue())); + + diskService.setMaxUploadSize(50000L); + assertThat(diskService.getMaxAppendSize(), is(50000L)); + + diskService.setMaxAppendSize(10000L); + assertThat(diskService.getMaxAppendSize(), is(10000L)); + } } diff --git a/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java b/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java new file mode 100644 index 00000000..5d250a7e --- /dev/null +++ b/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java @@ -0,0 +1,61 @@ +package me.desair.tus.server.util; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; + +public class StructuredHeaderUtilTest { + + @Test + public void testParseBoolean() { + assertThat(StructuredHeaderUtil.parseBoolean("?1"), is(Boolean.TRUE)); + assertThat(StructuredHeaderUtil.parseBoolean("?0"), is(Boolean.FALSE)); + assertThat(StructuredHeaderUtil.parseBoolean(" ?1 "), is(Boolean.TRUE)); + assertThat(StructuredHeaderUtil.parseBoolean("true"), nullValue()); + assertThat(StructuredHeaderUtil.parseBoolean(""), nullValue()); + assertThat(StructuredHeaderUtil.parseBoolean(null), nullValue()); + } + + @Test + public void testFormatBoolean() { + assertThat(StructuredHeaderUtil.formatBoolean(true), is("?1")); + assertThat(StructuredHeaderUtil.formatBoolean(false), is("?0")); + } + + @Test + public void testParseInteger() { + assertThat(StructuredHeaderUtil.parseInteger("12345"), is(12345L)); + assertThat(StructuredHeaderUtil.parseInteger(" 0 "), is(0L)); + assertThat(StructuredHeaderUtil.parseInteger("abc"), nullValue()); + assertThat(StructuredHeaderUtil.parseInteger(""), nullValue()); + assertThat(StructuredHeaderUtil.parseInteger(null), nullValue()); + } + + @Test + public void testParseDictionary() { + Map dict = + StructuredHeaderUtil.parseDictionary("max-size=100, max-age=3600, active"); + assertThat(dict.get("max-size"), is(100L)); + assertThat(dict.get("max-age"), is(3600L)); + assertThat(dict.get("active"), is(Boolean.TRUE)); + } + + @Test + public void testFormatDictionary() { + Map dict = new LinkedHashMap<>(); + dict.put("max-size", 100000L); + dict.put("completed", true); + assertThat(StructuredHeaderUtil.formatDictionary(dict), is("max-size=100000, completed=?1")); + } + + @Test + public void testSanitizeHeaderValue() { + assertThat(StructuredHeaderUtil.sanitizeHeaderValue("valid\r\nvalue"), is("validvalue")); + assertThat(StructuredHeaderUtil.sanitizeHeaderValue("test\0null"), is("testnull")); + assertThat(StructuredHeaderUtil.sanitizeHeaderValue(null), nullValue()); + } +} From d5c73d238d845ff57b69d46b8045cf132e1af830 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 4 Jul 2026 13:54:23 +0200 Subject: [PATCH 2/9] test: increase test coverage for resumable uploads implementation --- .../tus/server/rufh/HttpProblemDetails.java | 2 +- .../RufhAppendPatchRequestHandler.java | 2 +- .../RufhCreationPostRequestHandler.java | 5 +- .../rufh/handler/RufhHeadRequestHandler.java | 3 - .../validation/RufhCreationValidator.java | 7 +- .../me/desair/tus/server/CoverageGapTest.java | 111 +++++++ .../server/rufh/HttpProblemDetailsTest.java | 76 +++++ .../ResumableUploadsForHttpProtocolTest.java | 26 ++ .../RufhAppendPatchRequestHandlerTest.java | 112 +++++++ .../RufhCreationPostRequestHandlerTest.java | 281 ++++++++++++++++++ .../handler/RufhDeleteRequestHandlerTest.java | 15 + .../rufh/handler/RufhErrorHandlerTest.java | 110 +++++++ .../handler/RufhHeadRequestHandlerTest.java | 49 +++ .../RufhOptionsRequestHandlerTest.java | 46 +++ .../validation/RufhAppendValidatorTest.java | 107 +++++++ .../validation/RufhCreationValidatorTest.java | 78 +++++ .../validation/RufhSafePathValidatorTest.java | 6 + .../server/util/StructuredHeaderUtilTest.java | 27 ++ 18 files changed, 1049 insertions(+), 14 deletions(-) create mode 100644 src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java diff --git a/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java b/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java index 7a346d0a..d1b1ec27 100644 --- a/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java +++ b/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java @@ -188,7 +188,7 @@ public String toJson() { if (detail != null) { map.put("detail", detail); } - if (extraFields != null && !extraFields.isEmpty()) { + if (!extraFields.isEmpty()) { map.putAll(extraFields); } return formatJson(map); diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java index 07302d9b..2763daf7 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java @@ -96,6 +96,6 @@ public void process( } private boolean isUploadCompleted(UploadInfo uploadInfo) { - return uploadInfo != null && !uploadInfo.isUploadInProgress(); + return !uploadInfo.isUploadInProgress(); } } 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 27fb6a22..bf3c78cc 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 @@ -128,7 +128,7 @@ private boolean isExistingUpload( } private boolean isUploadCompleted(UploadInfo uploadInfo) { - return uploadInfo != null && !uploadInfo.isUploadInProgress(); + return !uploadInfo.isUploadInProgress(); } private String getUploadUri( @@ -145,9 +145,6 @@ private String getUploadUri( private void addUploadLimitHeader( TusServletResponse response, UploadStorageService uploadStorageService) { - if (uploadStorageService == null) { - return; - } Map limits = new LinkedHashMap<>(); long maxSize = uploadStorageService.getMaxUploadSize(); if (maxSize > 0) { diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java index 10f061e0..f811740a 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java @@ -70,9 +70,6 @@ public void process( private void addUploadLimitHeader( TusServletResponse response, UploadStorageService uploadStorageService) { - if (uploadStorageService == null) { - return; - } Map limits = new LinkedHashMap<>(); long maxSize = uploadStorageService.getMaxUploadSize(); if (maxSize > 0) { diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java index dad31f26..7d944496 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java @@ -53,11 +53,8 @@ public void validate( } } - Long maxUploadSize = uploadStorageService.getMaxUploadSize(); - if (maxUploadSize != null - && maxUploadSize > 0 - && uploadLength != null - && uploadLength > maxUploadSize) { + long maxUploadSize = uploadStorageService.getMaxUploadSize(); + if (maxUploadSize > 0 && uploadLength != null && uploadLength > maxUploadSize) { throw new TusException(413, "The requested upload length exceeds the maximum allowed size"); } diff --git a/src/test/java/me/desair/tus/server/CoverageGapTest.java b/src/test/java/me/desair/tus/server/CoverageGapTest.java index 66064226..2f24996e 100644 --- a/src/test/java/me/desair/tus/server/CoverageGapTest.java +++ b/src/test/java/me/desair/tus/server/CoverageGapTest.java @@ -235,4 +235,115 @@ public void testDiskStorageServiceSetAndGetMaxAppendSize() { storage.setMaxAppendSize(200L); assertThat(storage.getMaxAppendSize(), is(200L)); } + + @Test + public void testUploadStorageServiceDefaultMaxAppendSizeGreaterThanZero() { + UploadStorageService storage = + new UploadStorageService() { + // implementations of all required abstract methods... + @Override + public me.desair.tus.server.upload.UploadInfo getUploadInfo(String url, String key) { + return null; + } + + @Override + public me.desair.tus.server.upload.UploadInfo getUploadInfo( + me.desair.tus.server.upload.UploadId id) { + return null; + } + + @Override + public String getUploadUri() { + return null; + } + + @Override + public me.desair.tus.server.upload.UploadInfo append( + me.desair.tus.server.upload.UploadInfo info, java.io.InputStream stream) { + return null; + } + + @Override + public void setMaxUploadSize(Long size) {} + + @Override + public long getMaxUploadSize() { + return 1000L; + } // Returns > 0 + + @Override + public me.desair.tus.server.upload.UploadInfo create( + me.desair.tus.server.upload.UploadInfo info, String key) { + return null; + } + + @Override + public void update(me.desair.tus.server.upload.UploadInfo info) {} + + @Override + public java.io.InputStream getUploadedBytes(String url, String key) { + return null; + } + + @Override + public java.io.InputStream getUploadedBytes(me.desair.tus.server.upload.UploadId id) { + return null; + } + + @Override + public void copyUploadTo( + me.desair.tus.server.upload.UploadInfo info, java.io.OutputStream stream) {} + + @Override + public void cleanupExpiredUploads(UploadLockingService lock) {} + + @Override + public void removeLastNumberOfBytes( + me.desair.tus.server.upload.UploadInfo info, long bytes) {} + + @Override + public void terminateUpload(me.desair.tus.server.upload.UploadInfo info) {} + + @Override + public Long getUploadExpirationPeriod() { + return null; + } + + @Override + public void setUploadExpirationPeriod(Long period) {} + + @Override + public void setUploadConcatenationService( + me.desair.tus.server.upload.concatenation.UploadConcatenationService service) {} + + @Override + public me.desair.tus.server.upload.concatenation.UploadConcatenationService + getUploadConcatenationService() { + return null; + } + + @Override + public void setIdFactory(me.desair.tus.server.upload.UploadIdFactory factory) {} + }; + + assertThat(storage.getMaxAppendSize(), is(1000L)); + } + + @Test + public void testDiskStorageServiceMaxAppendSizeReflectionZeroOrNegative() throws Exception { + DiskStorageService storage = new DiskStorageService("/tmp"); + java.lang.reflect.Field field = DiskStorageService.class.getDeclaredField("maxAppendSize"); + field.setAccessible(true); + + // Set maxAppendSize to 0L directly via reflection to trigger branch: maxAppendSize != null && + // maxAppendSize > 0 (where first is true, second is false) + field.set(storage, 0L); + storage.setMaxUploadSize(500L); + // Should fallback to maxUploadSize + assertThat(storage.getMaxAppendSize(), is(500L)); + + // Set maxAppendSize to -10L + field.set(storage, -10L); + assertThat(storage.getMaxAppendSize(), is(500L)); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java b/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java index e49eb942..22841a98 100644 --- a/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java +++ b/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java @@ -1,5 +1,6 @@ package me.desair.tus.server.rufh; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -111,4 +112,79 @@ public void testInconsistentLengthProblemDetailsObject() throws Exception { + "\"status\":400," + "\"detail\":\"The provided Upload-Length does not match existing metadata\"}")); } + + @Test + public void testProblemDetailsOfAndSendHelpers() throws Exception { + HttpProblemDetails.sendProblemDetails( + new TusServletResponse(response), 418, "type-uri", "Teapot Title", "Teapot Detail", null); + assertThat(response.getStatus(), is(418)); + assertThat(response.getContentAsString(), containsString("\"title\":\"Teapot Title\"")); + + response = new MockHttpServletResponse(); + HttpProblemDetails.sendOffsetMismatch(new TusServletResponse(response), 100L, 200L); + assertThat(response.getStatus(), is(409)); + + response = new MockHttpServletResponse(); + HttpProblemDetails.sendCompletedUpload(new TusServletResponse(response), 400); + assertThat(response.getStatus(), is(400)); + + response = new MockHttpServletResponse(); + HttpProblemDetails.sendInconsistentLength(new TusServletResponse(response)); + assertThat(response.getStatus(), is(400)); + } + + @Test + public void testWriteToStandardHttpServletResponse() throws Exception { + HttpProblemDetails problem = HttpProblemDetails.forCompletedUpload(400); + problem.writeTo(response); + assertThat(response.getStatus(), is(400)); + assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); + assertThat(response.getContentAsString(), containsString("completed-upload")); + } + + @Test + public void testNullTypeAndDetail() throws Exception { + HttpProblemDetails problem = new HttpProblemDetails(500, null, "Title", null, null); + assertThat(problem.getType(), is("about:blank")); + assertThat(problem.getDetail(), is(org.hamcrest.CoreMatchers.nullValue())); + assertThat( + problem.toJson(), is("{\"type\":\"about:blank\",\"title\":\"Title\",\"status\":500}")); + } + + @Test + public void testJsonEscapingNullKeyAndSpecialChars() throws Exception { + java.util.Map extra = new java.util.LinkedHashMap<>(); + extra.put(null, "val-with-\\-\"-controls-\n-\r-\t"); + HttpProblemDetails problem = new HttpProblemDetails(500, "type", "Title", "Detail", extra); + String json = problem.toJson(); + // Null key escapes to empty string "" + assertThat(json, containsString("\"\":")); + // Escaped backslash \\ + assertThat(json, containsString("\\\\")); + // Escaped quote \" + assertThat(json, containsString("\\\"")); + // Escaped controls + assertThat(json, containsString("\\n")); + assertThat(json, containsString("\\r")); + assertThat(json, containsString("\\t")); + } + + @Test + public void testEmptyExtraFieldsAndBooleanValue() throws Exception { + // empty extraFields + HttpProblemDetails problem1 = + new HttpProblemDetails(500, "type", "Title", "Detail", new java.util.LinkedHashMap<>()); + assertThat( + problem1.toJson(), + is("{\"type\":\"type\",\"title\":\"Title\",\"status\":500,\"detail\":\"Detail\"}")); + + // boolean value in extraFields + java.util.Map extra = new java.util.LinkedHashMap<>(); + extra.put("active", true); + HttpProblemDetails problem2 = new HttpProblemDetails(500, "type", "Title", "Detail", extra); + assertThat( + problem2.toJson(), + is( + "{\"type\":\"type\",\"title\":\"Title\",\"status\":500,\"detail\":\"Detail\",\"active\":true}")); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java b/src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java new file mode 100644 index 00000000..d5953e4e --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java @@ -0,0 +1,26 @@ +package me.desair.tus.server.rufh; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.junit.Test; + +/** Tests for {@link ResumableUploadsForHttpProtocol} and its interim response strategies. */ +public class ResumableUploadsForHttpProtocolTest { + + @Test + public void testProtocolExtension() { + ResumableUploadsForHttpProtocol protocol = new ResumableUploadsForHttpProtocol(); + assertThat(protocol.getName(), is(ResumableUploadsForHttpProtocol.EXTENSION_NAME)); + + // Call with null to check it doesn't break + protocol.withInterimResponseStrategy(null); + + // Call with valid strategy + NoOpInterimResponseStrategy strategy = new NoOpInterimResponseStrategy(); + protocol.withInterimResponseStrategy(strategy); + + // Call sendInterimResponse to cover line 14 of NoOpInterimResponseStrategy + strategy.sendInterimResponse(null, null, 0L); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java index 957114c1..153f446c 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java @@ -91,4 +91,116 @@ public void testProcessPartialAppend() throws Exception { verify(lockingService) .registerInputStream(eq("/files/append-id"), any(InterruptibleInputStream.class)); } + + @Test + public void testProcessWithNullUploadInfo() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/append-id"); + when(storageService.getUploadInfo("/files/append-id", "owner")).thenReturn(null); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + // Early return, default status should be kept (usually 200 for mock response, but we didn't + // touch it) + assertThat(response.getStatus(), is(200)); + } + + @Test + public void testProcessWithNullInputStreamAndNoLocking() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/append-id"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("append-id")); + info.setOffset(1000L); + info.setLength(5000L); + + when(storageService.getUploadInfo("/files/append-id", "owner")).thenReturn(info); + + // Create a custom request where content input stream is null + TusServletRequest customRequest = + new TusServletRequest(request) { + @Override + public java.io.InputStream getContentInputStream() { + return null; + } + }; + + handler.process( + HttpMethod.PATCH, + customRequest, + new TusServletResponse(response), + storageService, + null, // No locking service + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1000")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + } + + @Test + public void testProcessCallFiveParameterMethodAndAppendReturnsNull() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/append-id"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + request.setContent("data".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("append-id")); + info.setOffset(1000L); + info.setLength(1004L); + + when(storageService.getUploadInfo("/files/append-id", "owner")).thenReturn(info); + // storageService.append returns null + when(storageService.append(any(UploadInfo.class), any())).thenReturn(null); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + "owner"); + + assertThat(response.getStatus(), is(200)); + // Offset should still be 1000 because append returned null + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1000")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + } + + @Test + public void testProcessIsFinishedCombinations() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/append-id"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "5000"); + // uploadComplete is not set or false, but upload is completed (offset == length) + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("append-id")); + info.setOffset(5000L); + info.setLength(5000L); // Completed + + when(storageService.getUploadInfo("/files/append-id", "owner")).thenReturn(info); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(200)); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + } } 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 d73823a4..33c1fd7c 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 @@ -117,4 +117,285 @@ public void testProcessCreationRegistersInputStream() throws Exception { verify(lockingService) .registerInputStream(eq("/files/creation-id"), any(InterruptibleInputStream.class)); } + + @Test + public void testProcessWithInterimResponseStrategyAndNullBaseUri() throws Exception { + final java.util.List interimUris = new java.util.ArrayList<>(); + me.desair.tus.server.rufh.InterimResponseStrategy strategy = + (res, uri, offset) -> interimUris.add(uri); + + handler = new RufhCreationPostRequestHandler(strategy); + + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setLength(5000L); + info.setOffset(0L); + + // Mock getUploadUri to return null to test fallback to requestURI + when(storageService.getUploadUri()).thenReturn(null); + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + assertThat(response.getStatus(), is(201)); + assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); + assertThat(interimUris.size(), is(1)); + assertThat(interimUris.get(0), is("/files/creation-id")); + } + + @Test + public void testProcessExistingUploadPatchReturnsEarly() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/existing-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("existing-id")); + when(storageService.getUploadInfo("/files/existing-id", "owner")).thenReturn(info); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + // Early return, default status should remain (200) and no location header set + assertThat(response.getStatus(), is(200)); + assertThat(response.getHeader(HttpHeader.LOCATION), org.hamcrest.CoreMatchers.nullValue()); + } + + @Test + public void testProcessWithoutLockingServiceAndAppendReturnsNull() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + request.setContent("creation body".getBytes()); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setLength(1000L); + info.setOffset(0L); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + // storageService.append returns null + when(storageService.append(any(UploadInfo.class), any())).thenReturn(null); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + "owner"); // Calls 5-parameter overload + + assertThat(response.getStatus(), is(201)); + assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("0")); + } + + @Test + public void testProcessUploadLimitsHeader() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setLength(5000L); + info.setOffset(0L); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + // Test when max upload size > 0 and max append size > 0 + when(storageService.getMaxUploadSize()).thenReturn(10000L); + when(storageService.getMaxAppendSize()).thenReturn(5000L); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat( + response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000, max-append-size=5000")); + + // Test when max upload size is 0 and max append size is null/0 + response = new MockHttpServletResponse(); + when(storageService.getMaxUploadSize()).thenReturn(0L); + when(storageService.getMaxAppendSize()).thenReturn(null); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), org.hamcrest.CoreMatchers.nullValue()); + } + + @Test + public void testProcessPatchCreationWhenUploadDoesNotExist() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/does-not-exist"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setLength(1000L); + info.setOffset(0L); + + when(storageService.getUploadInfo("/files/does-not-exist", "owner")).thenReturn(null); + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + assertThat(response.getStatus(), is(201)); + assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); + } + + @Test + public void testProcessNegativeLengthAndNullUploadId() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "-500"); + + UploadInfo info = new UploadInfo(); + info.setId(null); // Null ID + info.setOffset(0L); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + assertThat(response.getStatus(), is(201)); + // Location header ends with "/" because ID is empty string + assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/")); + } + + @Test + public void testProcessNullInputStreamOrZeroContentLengthAndFinishedState() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + // Content length is 0 + request.setContent(new byte[0]); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setOffset(1000L); + info.setLength(1000L); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + // Since UPLOAD_COMPLETE is ?1 (true), it should set status to 200 + assertThat(response.getStatus(), is(200)); + } + + @Test + public void testProcessBaseUriEndsWithSlashAndNullInputStreamWithContentLength() + throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setLength(5000L); + info.setOffset(0L); + + // baseUri ends with slash + when(storageService.getUploadUri()).thenReturn("/files/"); + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + + // Create a custom request where input stream is null but content length > 0 + TusServletRequest customRequest = + new TusServletRequest(request) { + @Override + public java.io.InputStream getContentInputStream() { + return null; + } + + @Override + public long getContentLengthLong() { + return 100L; + } + }; + + handler.process( + HttpMethod.POST, + customRequest, + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + assertThat(response.getStatus(), is(201)); + assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); + } + + @Test + public void testProcessFinishedStateWithUploadCompleted() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); // false, but offset == length below + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setOffset(1000L); + info.setLength(1000L); // Completed + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner"); + + assertThat(response.getStatus(), is(200)); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java index f546b692..222722ce 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java @@ -67,4 +67,19 @@ public void testProcessDeleteRequest() throws Exception { assertThat(response.getStatus(), is(204)); verify(storageService).terminateUpload(info); } + + @Test + public void testProcessWithNullUploadInfo() throws Exception { + request.setRequestURI("/files/delete-id"); + when(storageService.getUploadInfo("/files/delete-id", "owner")).thenReturn(null); + + handler.process( + HttpMethod.DELETE, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + "owner"); // Calls 5-parameter overload + + assertThat(response.getStatus(), is(204)); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java index acc7d69a..830632b1 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java @@ -77,4 +77,114 @@ public void testProcessErrorHandler409Mismatch() throws Exception { assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); assertThat(response.getContentAsString(), containsString("\"expected-offset\":1000")); } + + @Test + public void testProcessErrorHandler409MismatchNullUploadInfoOrOffset() throws Exception { + request.setRequestURI("/files/mismatch-id"); + // No UPLOAD_OFFSET header + when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(null); + + response.setStatus(409); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + "owner"); // 5-parameter overload + + assertThat(response.getStatus(), is(409)); + assertThat(response.getContentAsString(), containsString("\"expected-offset\":0")); + } + + @Test + public void testProcessErrorHandler400CompletedUpload() throws Exception { + request.setRequestURI("/files/completed-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("completed-id")); + info.setOffset(5000L); + info.setLength(5000L); // Completed + when(storageService.getUploadInfo("/files/completed-id", "owner")).thenReturn(info); + + response.setStatus(400); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(400)); + assertThat(response.getContentAsString(), containsString("completed-upload")); + } + + @Test + public void testProcessErrorHandler400InconsistentLength() throws Exception { + request.setRequestURI("/files/in-progress-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("in-progress-id")); + info.setOffset(1000L); + info.setLength(5000L); // In progress + when(storageService.getUploadInfo("/files/in-progress-id", "owner")).thenReturn(info); + + response.setStatus(400); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(400)); + assertThat(response.getContentAsString(), containsString("inconsistent-upload-length")); + } + + @Test + public void testProcessErrorHandler409MismatchNullOffset() throws Exception { + request.setRequestURI("/files/mismatch-id"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "2000"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("mismatch-id")); + info.setOffset(null); // Null offset + when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(info); + + response.setStatus(409); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(409)); + assertThat(response.getContentAsString(), containsString("\"expected-offset\":0")); + } + + @Test + public void testProcessErrorHandler400NullUploadInfo() throws Exception { + request.setRequestURI("/files/null-id"); + when(storageService.getUploadInfo("/files/null-id", "owner")).thenReturn(null); + + response.setStatus(400); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(400)); + assertThat(response.getContentAsString(), containsString("inconsistent-upload-length")); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java index 927d154f..5abef883 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java @@ -102,4 +102,53 @@ public void testProcessCompletedHeadRequest() throws Exception { assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("10000")); assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); } + + @Test + public void testProcessWithMaxAppendSizeAndNoLength() throws Exception { + request.setRequestURI("/files/incomplete-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("incomplete-id")); + info.setOffset(1000L); + // Length is null + when(storageService.getUploadInfo("/files/incomplete-id", "owner")).thenReturn(info); + when(storageService.getMaxUploadSize()).thenReturn(0L); + when(storageService.getMaxAppendSize()).thenReturn(5000L); + + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1000")); + assertThat(response.getHeader(HttpHeader.UPLOAD_LENGTH), org.hamcrest.CoreMatchers.nullValue()); + assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-append-size=5000")); + } + + @Test + public void testProcessWithNullMaxAppendSize() throws Exception { + request.setRequestURI("/files/incomplete-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("incomplete-id")); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/incomplete-id", "owner")).thenReturn(info); + when(storageService.getMaxUploadSize()).thenReturn(10000L); + when(storageService.getMaxAppendSize()).thenReturn(null); + + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000")); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java index 8dbee5a8..4012893f 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java @@ -67,4 +67,50 @@ public void testProcessOptionsRequest() throws Exception { assertThat( response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=100000, max-append-size=50000")); } + + @Test + public void testProcessWithNullUploadStorageService() throws Exception { + handler.process( + HttpMethod.OPTIONS, + new TusServletRequest(request), + new TusServletResponse(response), + null, + "owner"); // Calls 5-parameter overload with null storage + + assertThat(response.getStatus(), is(204)); + } + + @Test + public void testProcessWithNoLimits() throws Exception { + when(storageService.getMaxUploadSize()).thenReturn(0L); + when(storageService.getMaxAppendSize()).thenReturn(null); + + handler.process( + HttpMethod.OPTIONS, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), org.hamcrest.CoreMatchers.nullValue()); + } + + @Test + public void testProcessWithNullMaxAppendSize() throws Exception { + when(storageService.getMaxUploadSize()).thenReturn(10000L); + when(storageService.getMaxAppendSize()).thenReturn(null); + + handler.process( + HttpMethod.OPTIONS, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner"); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000")); + } } 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 39eb4a21..d52a8450 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 @@ -103,4 +103,111 @@ public void testValidateValidAppendRequest() throws Exception { validator.validate(HttpMethod.PATCH, request, storageService, "owner"); } + + @Test + public void testValidateUploadInfoNull() throws Exception { + request.setRequestURI("/files/does-not-exist"); + when(storageService.getUploadInfo("/files/does-not-exist", "owner")).thenReturn(null); + // Should return early and not throw any exception + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test(expected = TusException.class) + public void testValidateMismatchingUploadLength() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "6000"); // Current length is 5000 + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test + public void testValidateValidAppendRequestAlternativeContentType() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test(expected = TusException.class) + public void testValidateInvalidContentType() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, "text/plain"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test + public void testValidateMaxAppendSizeNullOrZeroOrContentLengthZero() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + // maxAppendSize is null + when(storageService.getMaxAppendSize()).thenReturn(null); + request.setContent("hello".getBytes()); + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + + // maxAppendSize is 0 + when(storageService.getMaxAppendSize()).thenReturn(0L); + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + + // contentLength is 0 + when(storageService.getMaxAppendSize()).thenReturn(5000L); + request.setContent(new byte[0]); + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test + public void testValidateUploadInfoNoLengthButProvidedLength() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5000"); // Provided length in request + + UploadInfo info = new UploadInfo(); + info.setLength(null); // Upload info has no length yet + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test + public void testValidateMatchingUploadLength() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5000"); // Matches the existing upload length + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java index 423f7c92..6476694d 100644 --- a/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java @@ -1,5 +1,7 @@ package me.desair.tus.server.rufh.validation; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.when; @@ -69,4 +71,80 @@ public void testValidateAcceptableLimits() throws Exception { validator.validate(HttpMethod.POST, request, storageService, null); } + + @Test + public void testValidateMaxUploadSizeNullOrZeroOrLengthNull() throws Exception { + // maxUploadSize is 0 + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + when(storageService.getMaxUploadSize()).thenReturn(0L); + validator.validate(HttpMethod.POST, request, storageService, null); + + // uploadLength is null + request.removeHeader(HttpHeader.UPLOAD_LENGTH); + when(storageService.getMaxUploadSize()).thenReturn(5000L); + validator.validate(HttpMethod.POST, request, storageService, null); + } + + @Test + public void testValidateMaxAppendSizeNullOrZeroOrContentLengthZero() throws Exception { + // maxAppendSize is null + request.setContent("hello".getBytes()); + when(storageService.getMaxAppendSize()).thenReturn(null); + validator.validate(HttpMethod.POST, request, storageService, null); + + // maxAppendSize is 0 + when(storageService.getMaxAppendSize()).thenReturn(0L); + validator.validate(HttpMethod.POST, request, storageService, null); + + // contentLength is <= 0 + request.setContent(new byte[0]); + when(storageService.getMaxAppendSize()).thenReturn(5000L); + validator.validate(HttpMethod.POST, request, storageService, null); + } + + @Test + public void testValidateUploadCompleteConsistency() throws Exception { + // Upload-Complete true, matching Content-Length + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + request.setContent("hello".getBytes()); + validator.validate(HttpMethod.POST, request, storageService, null); + + // Upload-Complete true, mismatching Content-Length + request.setContent("hello world".getBytes()); // 11 bytes vs 5 expected + try { + validator.validate(HttpMethod.POST, request, storageService, null); + org.junit.Assert.fail("Expected TusException for mismatching length"); + } catch (TusException e) { + assertThat(e.getStatus(), is(400)); + } + + // Upload-Complete false/null + request.removeHeader(HttpHeader.UPLOAD_COMPLETE); + validator.validate(HttpMethod.POST, request, storageService, null); + + // Upload-Complete true, but Upload-Length is null + request.removeHeader(HttpHeader.UPLOAD_LENGTH); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + request.setContent("hello".getBytes()); + validator.validate(HttpMethod.POST, request, storageService, null); + + // Upload-Complete true, Upload-Length non-null, but Content-Length < 0 + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5"); + MockHttpServletRequest negativeRequest = + new MockHttpServletRequest() { + @Override + public int getContentLength() { + return -1; + } + + @Override + public long getContentLengthLong() { + return -1; + } + }; + negativeRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "5"); + negativeRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + validator.validate(HttpMethod.POST, negativeRequest, storageService, null); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java index f9928bf1..835c378c 100644 --- a/src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java @@ -51,4 +51,10 @@ public void testValidateSafePath() throws Exception { request.setRequestURI("/files/valid-upload-id"); validator.validate(HttpMethod.POST, request, null, null); } + + @Test + public void testValidateNullPath() throws Exception { + request.setRequestURI(null); + validator.validate(HttpMethod.POST, request, null, null); + } } diff --git a/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java b/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java index 5d250a7e..ed05db58 100644 --- a/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java +++ b/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java @@ -52,6 +52,33 @@ public void testFormatDictionary() { assertThat(StructuredHeaderUtil.formatDictionary(dict), is("max-size=100000, completed=?1")); } + @Test + public void testFormatDictionaryEdgeCases() { + assertThat(StructuredHeaderUtil.formatDictionary(null), is("")); + assertThat(StructuredHeaderUtil.formatDictionary(new LinkedHashMap<>()), is("")); + + Map dict = new LinkedHashMap<>(); + dict.put("null-val", null); + dict.put("max-size", 100L); + assertThat(StructuredHeaderUtil.formatDictionary(dict), is("max-size=100")); + } + + @Test + public void testParseDictionaryEdgeCases() { + assertThat(StructuredHeaderUtil.parseDictionary(null).isEmpty(), is(true)); + assertThat(StructuredHeaderUtil.parseDictionary("").isEmpty(), is(true)); + assertThat(StructuredHeaderUtil.parseDictionary(" ").isEmpty(), is(true)); + + Map dict1 = StructuredHeaderUtil.parseDictionary(",,max-size=100"); + assertThat(dict1.get("max-size"), is(100L)); + + Map dict2 = + StructuredHeaderUtil.parseDictionary("active=?0, valid=?1, name=value"); + assertThat(dict2.get("active"), is(Boolean.FALSE)); + assertThat(dict2.get("valid"), is(Boolean.TRUE)); + assertThat(dict2.get("name"), is("value")); + } + @Test public void testSanitizeHeaderValue() { assertThat(StructuredHeaderUtil.sanitizeHeaderValue("valid\r\nvalue"), is("validvalue")); From 2b7bf623b3fc179cef506e4634b26e35c25675c5 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 4 Jul 2026 14:01:15 +0200 Subject: [PATCH 3/9] "fix: Update spec-watch to only run once per week" --- .github/workflows/spec-watch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spec-watch.yml b/.github/workflows/spec-watch.yml index 47106156..d330d4ef 100644 --- a/.github/workflows/spec-watch.yml +++ b/.github/workflows/spec-watch.yml @@ -2,7 +2,7 @@ name: Monitor IETF Resumable Uploads Spec Updates on: schedule: - - cron: '0 0 * * *' # Run daily at midnight UTC + - cron: "0 0 * * 6" # Run weekly on Saturday at midnight UTC workflow_dispatch: # Allow manual trigger jobs: From 738858051fcd64cbce8cf48b095bdd362d5d9035 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 4 Jul 2026 21:51:03 +0200 Subject: [PATCH 4/9] Refactor RUFH extension to exception-driven error handling, unify response writing, clean request handler and extension interfaces --- AGENTS.md | 1 + .../me/desair/tus/server/RequestHandler.java | 11 +- .../me/desair/tus/server/TusExtension.java | 13 +- .../tus/server/TusFileUploadService.java | 29 +- .../server/core/CorePatchRequestHandler.java | 13 +- .../InconsistentUploadLengthException.java | 15 + .../UploadAlreadyCompletedException.java | 15 + .../RufhAppendPatchRequestHandler.java | 20 +- .../RufhCreationPostRequestHandler.java | 20 +- .../handler/RufhDeleteRequestHandler.java | 18 +- .../server/rufh/handler/RufhErrorHandler.java | 42 +-- .../rufh/handler/RufhHeadRequestHandler.java | 21 +- .../handler/RufhOptionsRequestHandler.java | 21 +- .../rufh/validation/RufhAppendValidator.java | 11 +- .../validation/RufhCreationValidator.java | 4 +- .../tus/server/util/AbstractTusExtension.java | 42 ++- .../me/desair/tus/server/CoverageGapTest.java | 162 ++++++++++- .../tus/server/TusFileUploadServiceTest.java | 33 +++ .../core/CorePatchRequestHandlerTest.java | 39 +++ .../server/rufh/RufhProtocolAppendTest.java | 22 +- .../RufhAppendPatchRequestHandlerTest.java | 16 +- .../RufhCreationPostRequestHandlerTest.java | 37 ++- .../handler/RufhDeleteRequestHandlerTest.java | 7 +- .../rufh/handler/RufhErrorHandlerTest.java | 273 ++++++++++++++---- .../handler/RufhHeadRequestHandlerTest.java | 37 ++- .../RufhOptionsRequestHandlerTest.java | 31 +- 26 files changed, 730 insertions(+), 223 deletions(-) create mode 100644 src/main/java/me/desair/tus/server/exception/InconsistentUploadLengthException.java create mode 100644 src/main/java/me/desair/tus/server/exception/UploadAlreadyCompletedException.java diff --git a/AGENTS.md b/AGENTS.md index bb2179d5..8e8fda18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ When performing a release, please strictly follow the instructions outlined in t ### 4. Serializable UploadInfo & Backward Compatibility - The `UploadInfo` class is stored on disk serialized. If you modify fields in `UploadInfo`, you **must** preserve the `serialVersionUID = -8751200491586638308L` to ensure pre-existing uploads on disk do not trigger `InvalidClassException` upon deserialization. - Backward compatibility is paramount for this project. Breaking changes should only be done if all other options lead to ugly code and design. Breaking changes require a new major version. +- **Release Scope for Backward Compatibility**: Only maintain backward compatibility for classes, methods, or public API signatures that are present in the latest official Git release tag. Signatures, classes, or helper methods introduced in unreleased commits or feature branches do not require backward compatibility and should be refactored or deleted directly. ### 5. Lock Contention Resolution & InterruptibleInputStream - Request handlers that stream payload bytes to storage (`CorePatchRequestHandler`, `RufhCreationPostRequestHandler`, `RufhAppendPatchRequestHandler`) MUST wrap body input streams in `InterruptibleInputStream` and register them via `lockingService.registerInputStream(...)`. This ensures concurrent `HEAD` and `DELETE` requests can interrupt ongoing byte streams cleanly and resolve lock contention. diff --git a/src/main/java/me/desair/tus/server/RequestHandler.java b/src/main/java/me/desair/tus/server/RequestHandler.java index 86a35114..43d2511c 100644 --- a/src/main/java/me/desair/tus/server/RequestHandler.java +++ b/src/main/java/me/desair/tus/server/RequestHandler.java @@ -2,6 +2,7 @@ import java.io.IOException; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.TusServletRequest; @@ -38,7 +39,7 @@ void process( throws IOException, TusException; /** - * Process the given HTTP request with access to the upload locking service. + * Process the given HTTP request with access to the upload locking service and the exception. * * @param method The HTTP method of this request * @param servletRequest The HTTP request @@ -46,18 +47,22 @@ void process( * @param uploadStorageService The current upload storage service * @param uploadLockingService The upload locking service instance * @param ownerKey Identifier of the owner of this upload + * @param exception The exception that occurred + * @return An optional HttpProblemDetails to write to the response * @throws IOException When an I/O error occurs * @throws TusException When a protocol error occurs */ - default void process( + default HttpProblemDetails process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, - String ownerKey) + String ownerKey, + TusException exception) throws IOException, TusException { process(method, servletRequest, servletResponse, uploadStorageService, ownerKey); + return null; } /** diff --git a/src/main/java/me/desair/tus/server/TusExtension.java b/src/main/java/me/desair/tus/server/TusExtension.java index 24359d45..97d6dae1 100644 --- a/src/main/java/me/desair/tus/server/TusExtension.java +++ b/src/main/java/me/desair/tus/server/TusExtension.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.util.Collection; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.TusServletRequest; @@ -138,8 +139,8 @@ void handleError( throws IOException, TusException; /** - * React to validation or processing failures with access to the upload locking service and - * protocol version. + * React to validation or processing failures with access to the upload locking service, protocol + * version, and the caught TusException. * * @param method The HTTP method of this request * @param servletRequest The HTTP request @@ -148,19 +149,23 @@ void handleError( * @param uploadLockingService The upload locking service instance * @param ownerKey Identifier of the owner of this upload * @param version The protocol version of the request + * @param exception The exception that occurred + * @return An optional HttpProblemDetails to write to the response * @throws TusException When handling the error fails * @throws IOException When unable to read upload information */ - default void handleError( + default HttpProblemDetails handleError( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, String ownerKey, - ProtocolVersion version) + ProtocolVersion version, + TusException exception) throws IOException, TusException { handleError(method, servletRequest, servletResponse, uploadStorageService, ownerKey); + return null; } /** diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index 7d7c8106..294e020a 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -17,6 +17,7 @@ import me.desair.tus.server.download.DownloadExtension; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.expiration.ExpirationExtension; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.rufh.ResumableUploadsForHttpProtocol; import me.desair.tus.server.termination.TerminationExtension; import me.desair.tus.server.upload.UploadIdFactory; @@ -600,18 +601,24 @@ protected void processTusException( response.setStatus(status); + HttpProblemDetails problemDetails = null; try { for (TusExtension feature : enabledFeatures.values()) { if (!request.isProcessedBy(feature)) { request.addProcessor(feature); - feature.handleError( - method, - request, - response, - uploadStorageService, - uploadLockingService, - ownerKey, - version); + HttpProblemDetails pd = + feature.handleError( + method, + request, + response, + uploadStorageService, + uploadLockingService, + ownerKey, + version, + exception); + if (pd != null) { + problemDetails = pd; + } } } @@ -627,7 +634,11 @@ protected void processTusException( // response. Otherwise, we send the error response with the status and message from the // exception. if (!response.isCommitted()) { - response.sendError(status, message); + if (problemDetails != null) { + problemDetails.writeTo(response); + } else { + response.sendError(status, message); + } } } diff --git a/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java b/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java index 2c107019..3f33ef34 100644 --- a/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java @@ -8,6 +8,7 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.exception.UploadNotFoundException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; @@ -44,17 +45,18 @@ public void process( UploadStorageService uploadStorageService, String ownerKey) throws IOException, TusException { - process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey, null); } @Override - public void process( + public HttpProblemDetails process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService lockingService, - String ownerKey) + String ownerKey, + TusException exception) throws IOException, TusException { boolean found = true; @@ -66,11 +68,15 @@ public void process( } else if (uploadInfo.isUploadInProgress()) { try { InputStream stream = servletRequest.getContentInputStream(); + + // If a locking service is provided, wrap the input stream in an InterruptibleInputStream + // and register it with the locking service to allow for interruption of the upload. if (lockingService != null) { InterruptibleInputStream interruptibleStream = new InterruptibleInputStream(stream); lockingService.registerInputStream(servletRequest.getRequestURI(), interruptibleStream); stream = interruptibleStream; } + uploadInfo = uploadStorageService.append(uploadInfo, stream); if (uploadStorageService.isUploadDeduplicationEnabled() @@ -108,5 +114,6 @@ public void process( servletRequest.getRequestURI()); servletResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } + return null; } } diff --git a/src/main/java/me/desair/tus/server/exception/InconsistentUploadLengthException.java b/src/main/java/me/desair/tus/server/exception/InconsistentUploadLengthException.java new file mode 100644 index 00000000..c934b13c --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/InconsistentUploadLengthException.java @@ -0,0 +1,15 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when the provided upload length is inconsistent. */ +public class InconsistentUploadLengthException extends TusException { + /** + * Constructor. + * + * @param message Human-readable error message + */ + public InconsistentUploadLengthException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/UploadAlreadyCompletedException.java b/src/main/java/me/desair/tus/server/exception/UploadAlreadyCompletedException.java new file mode 100644 index 00000000..956307de --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/UploadAlreadyCompletedException.java @@ -0,0 +1,15 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when trying to append to or modify an upload that is already completed. */ +public class UploadAlreadyCompletedException extends TusException { + /** + * Constructor. + * + * @param message Human-readable error message + */ + public UploadAlreadyCompletedException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java index 2763daf7..d42b2881 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java @@ -5,6 +5,7 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; @@ -32,24 +33,14 @@ public boolean supports(HttpMethod method) { } @Override - public void process( - HttpMethod method, - TusServletRequest servletRequest, - TusServletResponse servletResponse, - UploadStorageService uploadStorageService, - String ownerKey) - throws IOException, TusException { - process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); - } - - @Override - public void process( + public HttpProblemDetails process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, - String ownerKey) + String ownerKey, + TusException exception) throws IOException, TusException { String requestUri = servletRequest.getRequestURI(); @@ -58,7 +49,7 @@ public void process( // If upload is null, this is a PATCH creation request and was handled by // RufhCreationPostRequestHandler if (uploadInfo == null) { - return; + return null; } String uploadCompleteHeader = servletRequest.getHeader(HttpHeader.UPLOAD_COMPLETE); @@ -93,6 +84,7 @@ public void process( } else { servletResponse.setStatus(204); } + return null; } private boolean isUploadCompleted(UploadInfo uploadInfo) { 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 bf3c78cc..7b1494ce 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 @@ -7,6 +7,7 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.rufh.InterimResponseStrategy; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; @@ -42,30 +43,20 @@ public boolean supports(HttpMethod method) { } @Override - public void process( - HttpMethod method, - TusServletRequest servletRequest, - TusServletResponse servletResponse, - UploadStorageService uploadStorageService, - String ownerKey) - throws IOException, TusException { - process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); - } - - @Override - public void process( + public HttpProblemDetails process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, - String ownerKey) + String ownerKey, + TusException exception) throws IOException, TusException { if (HttpMethod.PATCH.equals(method) && isExistingUpload(servletRequest, uploadStorageService, ownerKey)) { // Existing upload on PATCH request is handled by RufhAppendPatchRequestHandler - return; + return null; } UploadInfo uploadInfo = new UploadInfo(); @@ -118,6 +109,7 @@ && isExistingUpload(servletRequest, uploadStorageService, ownerKey)) { addUploadLimitHeader(servletResponse, uploadStorageService); } + return null; } private boolean isExistingUpload( diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java index 1056f695..daf330d1 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java @@ -3,6 +3,7 @@ import java.io.IOException; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; @@ -26,24 +27,14 @@ public boolean supports(HttpMethod method) { } @Override - public void process( - HttpMethod method, - TusServletRequest servletRequest, - TusServletResponse servletResponse, - UploadStorageService uploadStorageService, - String ownerKey) - throws IOException, TusException { - process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); - } - - @Override - public void process( + public HttpProblemDetails process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, - String ownerKey) + String ownerKey, + TusException exception) throws IOException, TusException { String requestUri = servletRequest.getRequestURI(); @@ -53,5 +44,6 @@ public void process( uploadStorageService.terminateUpload(uploadInfo); } servletResponse.setStatus(204); + return null; } } diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java index f3a15f84..6a092055 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java @@ -3,7 +3,10 @@ import java.io.IOException; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.InconsistentUploadLengthException; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UploadAlreadyCompletedException; +import me.desair.tus.server.exception.UploadOffsetMismatchException; import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; @@ -41,28 +44,17 @@ public boolean isErrorHandler() { } @Override - public void process( - HttpMethod method, - TusServletRequest servletRequest, - TusServletResponse servletResponse, - UploadStorageService uploadStorageService, - String ownerKey) - throws IOException, TusException { - process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); - } - - @Override - public void process( + public HttpProblemDetails process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, - String ownerKey) + String ownerKey, + TusException exception) throws IOException, TusException { - int status = servletResponse.getStatus(); - if (status == 409) { + if (exception instanceof UploadOffsetMismatchException) { // Section 7.1: Mismatching Offset UploadInfo uploadInfo = uploadStorageService.getUploadInfo(servletRequest.getRequestURI(), ownerKey); @@ -72,18 +64,16 @@ public void process( StructuredHeaderUtil.parseInteger(servletRequest.getHeader(HttpHeader.UPLOAD_OFFSET)); long provided = providedOffset != null ? providedOffset : 0L; - HttpProblemDetails.forOffsetMismatch(expectedOffset, provided).writeTo(servletResponse); + return HttpProblemDetails.forOffsetMismatch(expectedOffset, provided); - } else if (status == 400) { - UploadInfo uploadInfo = - uploadStorageService.getUploadInfo(servletRequest.getRequestURI(), ownerKey); - if (uploadInfo != null && !uploadInfo.isUploadInProgress()) { - // Section 7.2: Completed Upload - HttpProblemDetails.forCompletedUpload(400).writeTo(servletResponse); - } else { - // Section 7.3: Inconsistent Length - HttpProblemDetails.forInconsistentLength().writeTo(servletResponse); - } + } else if (exception instanceof UploadAlreadyCompletedException) { + // Section 7.2: Completed Upload + return HttpProblemDetails.forCompletedUpload(400); + + } else if (exception instanceof InconsistentUploadLengthException) { + // Section 7.3: Inconsistent Length + return HttpProblemDetails.forInconsistentLength(); } + return null; } } diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java index f811740a..9f8a005f 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java @@ -5,6 +5,8 @@ import java.util.Map; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; @@ -30,25 +32,15 @@ public boolean supports(HttpMethod method) { } @Override - public void process( - HttpMethod method, - TusServletRequest servletRequest, - TusServletResponse servletResponse, - UploadStorageService uploadStorageService, - String ownerKey) - throws IOException { - process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); - } - - @Override - public void process( + public HttpProblemDetails process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, - String ownerKey) - throws IOException { + String ownerKey, + TusException exception) + throws IOException, TusException { String requestUri = servletRequest.getRequestURI(); UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); @@ -66,6 +58,7 @@ public void process( addUploadLimitHeader(servletResponse, uploadStorageService); servletResponse.setHeader(HttpHeader.CACHE_CONTROL, "no-store"); + return null; } private void addUploadLimitHeader( diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java index c5208c42..80731798 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java @@ -6,6 +6,8 @@ import java.util.Map; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.AbstractRequestHandler; @@ -29,25 +31,15 @@ public boolean supports(HttpMethod method) { } @Override - public void process( - HttpMethod method, - TusServletRequest servletRequest, - TusServletResponse servletResponse, - UploadStorageService uploadStorageService, - String ownerKey) - throws IOException { - process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey); - } - - @Override - public void process( + public HttpProblemDetails process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, - String ownerKey) - throws IOException { + String ownerKey, + TusException exception) + throws IOException, TusException { servletResponse.setHeader( HttpHeader.ACCEPT_PATCH, @@ -56,6 +48,7 @@ public void process( addUploadLimitHeader(servletResponse, uploadStorageService); servletResponse.setStatus(HttpServletResponse.SC_NO_CONTENT); + return null; } private void addUploadLimitHeader( 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 3772c0cb..d6791c05 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 @@ -5,7 +5,10 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.InconsistentUploadLengthException; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UploadAlreadyCompletedException; +import me.desair.tus.server.exception.UploadOffsetMismatchException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.StructuredHeaderUtil; @@ -57,7 +60,7 @@ public void validate( } if (!uploadInfo.isUploadInProgress()) { - throw new TusException(400, "Upload resource is already completed"); + throw new UploadAlreadyCompletedException("Upload resource is already completed"); } Long maxAppendSize = uploadStorageService.getMaxAppendSize(); @@ -83,8 +86,7 @@ public void validate( long currentOffset = uploadInfo.getOffset(); if (providedOffset != currentOffset) { - throw new TusException( - 409, + throw new UploadOffsetMismatchException( "Upload-Offset " + providedOffset + " does not match server offset " + currentOffset); } @@ -106,8 +108,7 @@ public void validate( Long providedLength = StructuredHeaderUtil.parseInteger(uploadLengthHeader); if (providedLength != null && uploadInfo.hasLength()) { if (!providedLength.equals(uploadInfo.getLength())) { - throw new TusException( - 400, + throw new InconsistentUploadLengthException( "Provided Upload-Length (" + providedLength + ") does not match existing upload length (" diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java index 7d944496..02da54bb 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java @@ -5,6 +5,7 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.InconsistentUploadLengthException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.StructuredHeaderUtil; @@ -43,8 +44,7 @@ public void validate( // Upload-Complete: ?1 if (uploadLength != null && Boolean.TRUE.equals(uploadComplete) && contentLength >= 0) { if (uploadLength != contentLength) { - throw new TusException( - 400, + throw new InconsistentUploadLengthException( "The provided Upload-Length (" + uploadLength + ") does not match Content-Length (" diff --git a/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java b/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java index c00b6e75..8bf839c3 100644 --- a/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java +++ b/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java @@ -10,6 +10,7 @@ import me.desair.tus.server.RequestValidator; import me.desair.tus.server.TusExtension; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; @@ -101,7 +102,8 @@ public void process( servletResponse, uploadStorageService, uploadLockingService, - ownerKey); + ownerKey, + null); } } } @@ -114,30 +116,54 @@ public void handleError( UploadStorageService uploadStorageService, String ownerKey) throws IOException, TusException { - handleError( - method, request, response, uploadStorageService, null, ownerKey, ProtocolVersion.TUS_1_0_0); + HttpProblemDetails pd = + handleError( + method, + request, + response, + uploadStorageService, + null, + ownerKey, + ProtocolVersion.TUS_1_0_0, + null); + if (pd != null) { + pd.writeTo(response); + } } @Override - public void handleError( + public HttpProblemDetails handleError( HttpMethod method, TusServletRequest request, TusServletResponse response, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, String ownerKey, - ProtocolVersion version) + ProtocolVersion version, + TusException exception) throws IOException, TusException { if (!isApplicable(method, version)) { - return; + return null; } + HttpProblemDetails problemDetails = null; for (RequestHandler requestHandler : requestHandlers) { if (requestHandler.supports(method) && requestHandler.isErrorHandler()) { - requestHandler.process( - method, request, response, uploadStorageService, uploadLockingService, ownerKey); + HttpProblemDetails pd = + requestHandler.process( + method, + request, + response, + uploadStorageService, + uploadLockingService, + ownerKey, + exception); + if (pd != null) { + problemDetails = pd; + } } } + return problemDetails; } } diff --git a/src/test/java/me/desair/tus/server/CoverageGapTest.java b/src/test/java/me/desair/tus/server/CoverageGapTest.java index 2f24996e..8ca78524 100644 --- a/src/test/java/me/desair/tus/server/CoverageGapTest.java +++ b/src/test/java/me/desair/tus/server/CoverageGapTest.java @@ -1,5 +1,6 @@ package me.desair.tus.server; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; @@ -7,6 +8,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.upload.disk.DiskStorageService; @@ -66,7 +68,7 @@ public void handleError( } private static class DummyAbstractExtension extends AbstractTusExtension { - boolean handleError7Called = false; + boolean handleError8Called = false; @Override public String getName() { @@ -85,16 +87,75 @@ protected void initValidators(java.util.List requestValidators protected void initRequestHandlers(java.util.List requestHandlers) {} @Override - public void handleError( + public HttpProblemDetails handleError( HttpMethod method, TusServletRequest request, TusServletResponse response, UploadStorageService uploadStorageService, UploadLockingService uploadLockingService, String ownerKey, - ProtocolVersion version) + ProtocolVersion version, + TusException exception) throws IOException, TusException { - handleError7Called = true; + handleError8Called = true; + return null; + } + } + + private static class DummyAbstractExtensionWithProblemDetails extends AbstractTusExtension { + DummyAbstractExtensionWithProblemDetails() { + super(); + } + + @Override + public String getName() { + return "dummy-pd"; + } + + @Override + public java.util.Collection getMinimalSupportedHttpMethods() { + return java.util.Collections.emptyList(); + } + + @Override + protected void initValidators(java.util.List requestValidators) {} + + @Override + protected void initRequestHandlers(java.util.List requestHandlers) { + requestHandlers.add( + new RequestHandler() { + @Override + public boolean supports(HttpMethod method) { + return true; + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws java.io.IOException, TusException {} + + @Override + public boolean isErrorHandler() { + return true; + } + + @Override + public HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService lockingService, + String ownerKey, + TusException exception) + throws java.io.IOException, TusException { + return HttpProblemDetails.forCompletedUpload(400); + } + }); } } @@ -108,15 +169,44 @@ public void testTusExtensionDefaultMethods() throws Exception { extension.process(HttpMethod.POST, null, null, null, null, null, ProtocolVersion.TUS_1_0_0); assertThat(extension.processCalled, is(true)); - extension.handleError(HttpMethod.POST, null, null, null, null, null, ProtocolVersion.TUS_1_0_0); + extension.handleError( + HttpMethod.POST, null, null, null, null, null, ProtocolVersion.TUS_1_0_0, null); assertThat(extension.handleErrorCalled, is(true)); + + extension.handleErrorCalled = false; + HttpProblemDetails pd = + extension.handleError( + HttpMethod.POST, + null, + null, + null, + null, + null, + ProtocolVersion.TUS_1_0_0, + new TusException(400, "Error")); + assertThat(extension.handleErrorCalled, is(true)); + assertThat(pd, nullValue()); } @Test public void testAbstractTusExtensionHandleError5Args() throws Exception { DummyAbstractExtension ext = new DummyAbstractExtension(); ext.handleError(HttpMethod.POST, null, null, null, null); - assertThat(ext.handleError7Called, is(true)); + assertThat(ext.handleError8Called, is(true)); + } + + @Test + public void testAbstractTusExtensionHandleError5ArgsWithProblemDetails() throws Exception { + DummyAbstractExtensionWithProblemDetails ext = new DummyAbstractExtensionWithProblemDetails(); + org.springframework.mock.web.MockHttpServletResponse response = + new org.springframework.mock.web.MockHttpServletResponse(); + ext.handleError( + HttpMethod.POST, + new TusServletRequest(new org.springframework.mock.web.MockHttpServletRequest()), + new TusServletResponse(response), + null, + "owner"); + assertThat(response.getContentAsString(), containsString("completed-upload")); } @Test @@ -346,4 +436,64 @@ public void testDiskStorageServiceMaxAppendSizeReflectionZeroOrNegative() throws field.set(storage, -10L); assertThat(storage.getMaxAppendSize(), is(500L)); } + + private static class DummyAbstractExtensionWithoutOverride extends AbstractTusExtension { + @Override + public String getName() { + return "dummy-no-override"; + } + + @Override + public java.util.Collection getMinimalSupportedHttpMethods() { + return java.util.Collections.emptyList(); + } + + @Override + protected void initValidators(java.util.List requestValidators) {} + + @Override + protected void initRequestHandlers(java.util.List requestHandlers) {} + } + + @Test + public void testAbstractTusExtensionHandleError8ArgsNullProblemDetails() throws Exception { + DummyAbstractExtensionWithoutOverride ext = new DummyAbstractExtensionWithoutOverride(); + ext.handleError(HttpMethod.POST, null, null, null, null, null, ProtocolVersion.TUS_1_0_0, null); + } + + @Test + public void testRequestHandlerDefaultProcessBranchCoverage() throws Exception { + RequestHandler mockHandler = + new RequestHandler() { + @Override + public boolean supports(HttpMethod method) { + return true; + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException {} + + @Override + public boolean isErrorHandler() { + return false; + } + }; + + // Test branch: uploadLockingService != null, servletRequest == null + UploadLockingService mockLocking = org.mockito.Mockito.mock(UploadLockingService.class); + mockHandler.process(HttpMethod.PATCH, null, null, null, mockLocking, "owner", null); + + // Test branch: uploadLockingService == null, servletRequest != null + TusServletRequest mockRequest = org.mockito.Mockito.mock(TusServletRequest.class); + mockHandler.process(HttpMethod.PATCH, mockRequest, null, null, null, "owner", null); + + // Test branch: uploadLockingService != null, servletRequest != null + mockHandler.process(HttpMethod.PATCH, mockRequest, null, null, mockLocking, "owner", null); + } } diff --git a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java index 9a154610..10b0971a 100644 --- a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java +++ b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java @@ -349,4 +349,37 @@ public void testProcessTusExceptionRufhNon409() throws Exception { assertThat(mockResp.getStatus(), is(415)); } + + @Test + public void testProcessTusExceptionResponseCommitted() throws Exception { + UploadLockingService mockLockingService = mock(UploadLockingService.class); + UploadLock mockLock = mock(UploadLock.class); + when(mockLockingService.lockUploadByUri(anyString())).thenReturn(mockLock); + + UploadStorageService mockStorage = mock(UploadStorageService.class); + UploadInfo info = new UploadInfo(); + when(mockStorage.getUploadInfo(anyString(), any())).thenReturn(info); + + org.springframework.mock.web.MockHttpServletRequest mockReq = + new org.springframework.mock.web.MockHttpServletRequest(); + jakarta.servlet.http.HttpServletResponse mockResp = + mock(jakarta.servlet.http.HttpServletResponse.class); + when(mockResp.isCommitted()).thenReturn(true); + + mockReq.setMethod("PATCH"); + mockReq.setRequestURI("/files/test"); + // Cause a validation error (415) + mockReq.addHeader(HttpHeader.CONTENT_TYPE, "text/plain"); + + TusFileUploadService service = + new TusFileUploadService() + .withUploadLockingService(mockLockingService) + .withUploadStorageService(mockStorage) + .withSupportedProtocolVersions(ProtocolVersion.RUFH); + + service.process(mockReq, mockResp, "owner"); + + // Since response is committed, sendError should not be called + verify(mockResp, never()).sendError(anyInt(), anyString()); + } } diff --git a/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java index f248e311..95a14177 100644 --- a/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java @@ -174,10 +174,49 @@ public void processWithLockingService() throws Exception { new TusServletResponse(servletResponse), uploadStorageService, mockLocking, + null, null); verify(mockLocking, times(1)) .registerInputStream(eq(servletRequest.getRequestURI()), any(InputStream.class)); verify(uploadStorageService, times(1)).append(eq(info), any(InputStream.class)); } + + @Test + public void testProcess7ParamsBranchCoverage() throws Exception { + UploadLockingService mockLocking = mock(UploadLockingService.class); + + // lockingService != null, servletRequest == null + try { + handler.process( + HttpMethod.PATCH, + null, + new TusServletResponse(servletResponse), + uploadStorageService, + mockLocking, + "owner", + null); + } catch (Exception e) { + // Expected, we just need to hit the branch check + } + + // lockingService == null, servletRequest != null + UploadInfo info = new UploadInfo(); + info.setId(new UploadId(UUID.randomUUID())); + info.setOffset(2L); + info.setLength(10L); + when(uploadStorageService.getUploadInfo(nullable(String.class), nullable(String.class))) + .thenReturn(info); + when(uploadStorageService.append(any(UploadInfo.class), any(InputStream.class))) + .thenReturn(info); + + handler.process( + HttpMethod.PATCH, + new TusServletRequest(servletRequest), + new TusServletResponse(servletResponse), + uploadStorageService, + null, + "owner", + null); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java index f134f062..215be752 100644 --- a/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java @@ -140,14 +140,20 @@ public void testCompletedUploadFormatsProblemJson() throws Exception { request.setRequestURI("/files/test-id"); TusServletRequest tusReq = new TusServletRequest(request, true); - protocol.handleError( - HttpMethod.PATCH, - tusReq, - new TusServletResponse(errResponse), - storageService, - lockingService, - null, - ProtocolVersion.RUFH); + me.desair.tus.server.rufh.HttpProblemDetails problemDetails = + protocol.handleError( + HttpMethod.PATCH, + tusReq, + new TusServletResponse(errResponse), + storageService, + lockingService, + null, + ProtocolVersion.RUFH, + new me.desair.tus.server.exception.UploadAlreadyCompletedException( + "The upload resource is already completed and cannot be modified")); + if (problemDetails != null) { + problemDetails.writeTo(new TusServletResponse(errResponse)); + } assertThat(errResponse.getStatus(), is(400)); assertThat(errResponse.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java index 153f446c..31b391b1 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java @@ -82,7 +82,8 @@ public void testProcessPartialAppend() throws Exception { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1011")); @@ -104,7 +105,8 @@ public void testProcessWithNullUploadInfo() throws Exception { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); // Early return, default status should be kept (usually 200 for mock response, but we didn't // touch it) @@ -140,7 +142,8 @@ public java.io.InputStream getContentInputStream() { new TusServletResponse(response), storageService, null, // No locking service - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1000")); @@ -169,7 +172,9 @@ public void testProcessCallFiveParameterMethodAndAppendReturnsNull() throws Exce new TusServletRequest(request), new TusServletResponse(response), storageService, - "owner"); + null, + "owner", + null); assertThat(response.getStatus(), is(200)); // Offset should still be 1000 because append returned null @@ -198,7 +203,8 @@ public void testProcessIsFinishedCombinations() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(200)); assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); 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 33c1fd7c..0af73d61 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 @@ -79,7 +79,8 @@ public void testProcessPartialUploadCreation() throws Exception { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(201)); assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); @@ -112,7 +113,8 @@ public void testProcessCreationRegistersInputStream() throws Exception { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); verify(lockingService) .registerInputStream(eq("/files/creation-id"), any(InterruptibleInputStream.class)); @@ -147,7 +149,8 @@ public void testProcessWithInterimResponseStrategyAndNullBaseUri() throws Except new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(201)); assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); @@ -170,7 +173,8 @@ public void testProcessExistingUploadPatchReturnsEarly() throws Exception { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); // Early return, default status should remain (200) and no location header set assertThat(response.getStatus(), is(200)); @@ -198,7 +202,9 @@ public void testProcessWithoutLockingServiceAndAppendReturnsNull() throws Except new TusServletRequest(request), new TusServletResponse(response), storageService, - "owner"); // Calls 5-parameter overload + null, + "owner", + null); assertThat(response.getStatus(), is(201)); assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); @@ -230,7 +236,8 @@ public void testProcessUploadLimitsHeader() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat( response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000, max-append-size=5000")); @@ -246,7 +253,8 @@ public void testProcessUploadLimitsHeader() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), org.hamcrest.CoreMatchers.nullValue()); } @@ -272,7 +280,8 @@ public void testProcessPatchCreationWhenUploadDoesNotExist() throws Exception { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(201)); assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); @@ -296,7 +305,8 @@ public void testProcessNegativeLengthAndNullUploadId() throws Exception { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(201)); // Location header ends with "/" because ID is empty string @@ -324,7 +334,8 @@ public void testProcessNullInputStreamOrZeroContentLengthAndFinishedState() thro new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); // Since UPLOAD_COMPLETE is ?1 (true), it should set status to 200 assertThat(response.getStatus(), is(200)); @@ -367,7 +378,8 @@ public long getContentLengthLong() { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(201)); assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); @@ -393,7 +405,8 @@ public void testProcessFinishedStateWithUploadCompleted() throws Exception { new TusServletResponse(response), storageService, lockingService, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(200)); assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java index 222722ce..88508499 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java @@ -62,7 +62,8 @@ public void testProcessDeleteRequest() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); verify(storageService).terminateUpload(info); @@ -78,7 +79,9 @@ public void testProcessWithNullUploadInfo() throws Exception { new TusServletRequest(request), new TusServletResponse(response), storageService, - "owner"); // Calls 5-parameter overload + null, + "owner", + null); assertThat(response.getStatus(), is(204)); } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java index 830632b1..5bdcb264 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java @@ -8,6 +8,10 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.InconsistentUploadLengthException; +import me.desair.tus.server.exception.UploadAlreadyCompletedException; +import me.desair.tus.server.exception.UploadOffsetMismatchException; +import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadId; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; @@ -63,15 +67,20 @@ public void testProcessErrorHandler409Mismatch() throws Exception { info.setOffset(1000L); // Server offset is 1000 when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(info); - response.setStatus(409); // Mismatch set by validation exception handling flow - - handler.process( - HttpMethod.PATCH, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - null, - "owner"); + UploadOffsetMismatchException exception = new UploadOffsetMismatchException("Mismatch"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + if (problem != null) { + problem.writeTo(new TusServletResponse(response)); + } assertThat(response.getStatus(), is(409)); assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); @@ -84,14 +93,20 @@ public void testProcessErrorHandler409MismatchNullUploadInfoOrOffset() throws Ex // No UPLOAD_OFFSET header when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(null); - response.setStatus(409); - - handler.process( - HttpMethod.PATCH, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - "owner"); // 5-parameter overload + UploadOffsetMismatchException exception = new UploadOffsetMismatchException("Mismatch"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + if (problem != null) { + problem.writeTo(new TusServletResponse(response)); + } assertThat(response.getStatus(), is(409)); assertThat(response.getContentAsString(), containsString("\"expected-offset\":0")); @@ -107,15 +122,20 @@ public void testProcessErrorHandler400CompletedUpload() throws Exception { info.setLength(5000L); // Completed when(storageService.getUploadInfo("/files/completed-id", "owner")).thenReturn(info); - response.setStatus(400); - - handler.process( - HttpMethod.PATCH, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - null, - "owner"); + UploadAlreadyCompletedException exception = new UploadAlreadyCompletedException("Completed"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + if (problem != null) { + problem.writeTo(new TusServletResponse(response)); + } assertThat(response.getStatus(), is(400)); assertThat(response.getContentAsString(), containsString("completed-upload")); @@ -131,15 +151,21 @@ public void testProcessErrorHandler400InconsistentLength() throws Exception { info.setLength(5000L); // In progress when(storageService.getUploadInfo("/files/in-progress-id", "owner")).thenReturn(info); - response.setStatus(400); - - handler.process( - HttpMethod.PATCH, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - null, - "owner"); + InconsistentUploadLengthException exception = + new InconsistentUploadLengthException("Inconsistent"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + if (problem != null) { + problem.writeTo(new TusServletResponse(response)); + } assertThat(response.getStatus(), is(400)); assertThat(response.getContentAsString(), containsString("inconsistent-upload-length")); @@ -155,15 +181,20 @@ public void testProcessErrorHandler409MismatchNullOffset() throws Exception { info.setOffset(null); // Null offset when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(info); - response.setStatus(409); - - handler.process( - HttpMethod.PATCH, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - null, - "owner"); + UploadOffsetMismatchException exception = new UploadOffsetMismatchException("Mismatch"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + if (problem != null) { + problem.writeTo(new TusServletResponse(response)); + } assertThat(response.getStatus(), is(409)); assertThat(response.getContentAsString(), containsString("\"expected-offset\":0")); @@ -174,17 +205,153 @@ public void testProcessErrorHandler400NullUploadInfo() throws Exception { request.setRequestURI("/files/null-id"); when(storageService.getUploadInfo("/files/null-id", "owner")).thenReturn(null); - response.setStatus(400); - - handler.process( - HttpMethod.PATCH, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - null, - "owner"); + InconsistentUploadLengthException exception = + new InconsistentUploadLengthException("Inconsistent"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + if (problem != null) { + problem.writeTo(new TusServletResponse(response)); + } assertThat(response.getStatus(), is(400)); assertThat(response.getContentAsString(), containsString("inconsistent-upload-length")); } + + @Test + public void testProcessWithUploadOffsetMismatchException() throws Exception { + request.setRequestURI("/files/mismatch-id"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "2000"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("mismatch-id")); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(info); + + UploadOffsetMismatchException exception = new UploadOffsetMismatchException("Mismatch!"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + + assertThat(problem.getStatus(), is(409)); + assertThat(problem.getType(), containsString("mismatching-upload-offset")); + assertThat(problem.getExtraFields().get("expected-offset"), is(1000L)); + } + + @Test + public void testProcessWithUploadAlreadyCompletedException() throws Exception { + UploadAlreadyCompletedException exception = new UploadAlreadyCompletedException("Completed!"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + + assertThat(problem.getStatus(), is(400)); + assertThat(problem.getType(), containsString("completed-upload")); + } + + @Test + public void testProcessWithInconsistentUploadLengthException() throws Exception { + InconsistentUploadLengthException exception = + new InconsistentUploadLengthException("Inconsistent!"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + + assertThat(problem.getStatus(), is(400)); + assertThat(problem.getType(), containsString("inconsistent-upload-length")); + } + + @Test + public void testProcessWithUploadOffsetMismatchExceptionNullInfo() throws Exception { + request.setRequestURI("/files/mismatch-id"); + when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(null); + + UploadOffsetMismatchException exception = new UploadOffsetMismatchException("Mismatch!"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + + assertThat(problem.getStatus(), is(409)); + assertThat(problem.getExtraFields().get("expected-offset"), is(0L)); + } + + @Test + public void testProcessWithUploadOffsetMismatchExceptionNullOffset() throws Exception { + request.setRequestURI("/files/mismatch-id"); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "2000"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("mismatch-id")); + info.setOffset(null); + when(storageService.getUploadInfo("/files/mismatch-id", "owner")).thenReturn(info); + + UploadOffsetMismatchException exception = new UploadOffsetMismatchException("Mismatch!"); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + exception); + + assertThat(problem.getStatus(), is(409)); + assertThat(problem.getExtraFields().get("expected-offset"), is(0L)); + } + + @Test + public void testProcessWithNonErrorStatus() throws Exception { + response.setStatus(200); + + HttpProblemDetails problem = + handler.process( + HttpMethod.PATCH, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + null); + + // problem is null, so response body should remain empty + assertThat(problem, org.hamcrest.CoreMatchers.nullValue()); + assertThat(response.getContentAsString(), is("")); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java index 5abef883..4d6a08d4 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java @@ -66,7 +66,8 @@ public void testProcessIncompleteHeadRequest() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("2500")); @@ -96,7 +97,8 @@ public void testProcessCompletedHeadRequest() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("10000")); @@ -121,7 +123,8 @@ public void testProcessWithMaxAppendSizeAndNoLength() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1000")); @@ -146,9 +149,35 @@ public void testProcessWithNullMaxAppendSize() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000")); } + + @Test + public void testProcessHeadRequest5Params() throws Exception { + request.setRequestURI("/files/incomplete-id"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("incomplete-id")); + info.setOffset(2500L); + info.setLength(10000L); + when(storageService.getUploadInfo("/files/incomplete-id", "owner")).thenReturn(info); + when(storageService.getMaxUploadSize()).thenReturn(500000L); + + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + null); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("2500")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java index 4012893f..ad9e0087 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java @@ -57,7 +57,8 @@ public void testProcessOptionsRequest() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat( @@ -75,7 +76,9 @@ public void testProcessWithNullUploadStorageService() throws Exception { new TusServletRequest(request), new TusServletResponse(response), null, - "owner"); // Calls 5-parameter overload with null storage + null, + "owner", + null); assertThat(response.getStatus(), is(204)); } @@ -91,7 +94,8 @@ public void testProcessWithNoLimits() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), org.hamcrest.CoreMatchers.nullValue()); @@ -108,7 +112,26 @@ public void testProcessWithNullMaxAppendSize() throws Exception { new TusServletResponse(response), storageService, null, - "owner"); + "owner", + null); + + assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000")); + } + + @Test + public void testProcessWithZeroMaxAppendSize() throws Exception { + when(storageService.getMaxUploadSize()).thenReturn(10000L); + when(storageService.getMaxAppendSize()).thenReturn(0L); + + handler.process( + HttpMethod.OPTIONS, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + null); assertThat(response.getStatus(), is(204)); assertThat(response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000")); From f4b33f9d0dd25303217d05463d4e35402f2e48e9 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 4 Jul 2026 21:55:05 +0200 Subject: [PATCH 5/9] Make RequestHandler.process(5 params) default method to support clean compile in Java 21 --- .../me/desair/tus/server/RequestHandler.java | 6 ++-- .../me/desair/tus/server/CoverageGapTest.java | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/me/desair/tus/server/RequestHandler.java b/src/main/java/me/desair/tus/server/RequestHandler.java index 43d2511c..86b3275a 100644 --- a/src/main/java/me/desair/tus/server/RequestHandler.java +++ b/src/main/java/me/desair/tus/server/RequestHandler.java @@ -30,13 +30,15 @@ public interface RequestHandler { * @throws IOException When an I/O error occurs * @throws TusException When a protocol error occurs */ - void process( + default void process( HttpMethod method, TusServletRequest servletRequest, TusServletResponse servletResponse, UploadStorageService uploadStorageService, String ownerKey) - throws IOException, TusException; + throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey, null); + } /** * Process the given HTTP request with access to the upload locking service and the exception. diff --git a/src/test/java/me/desair/tus/server/CoverageGapTest.java b/src/test/java/me/desair/tus/server/CoverageGapTest.java index 8ca78524..64c3c4c3 100644 --- a/src/test/java/me/desair/tus/server/CoverageGapTest.java +++ b/src/test/java/me/desair/tus/server/CoverageGapTest.java @@ -495,5 +495,34 @@ public boolean isErrorHandler() { // Test branch: uploadLockingService != null, servletRequest != null mockHandler.process(HttpMethod.PATCH, mockRequest, null, null, mockLocking, "owner", null); + + // Test default 5-parameter process delegation to 7-parameter process + RequestHandler mockHandler7 = + new RequestHandler() { + @Override + public boolean supports(HttpMethod method) { + return true; + } + + @Override + public boolean isErrorHandler() { + return false; + } + + @Override + public HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + return null; + } + }; + + mockHandler7.process(HttpMethod.PATCH, null, null, null, "owner"); } } From fd0fd69d8df47ef46e2e106d8444d1c34df63814 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 4 Jul 2026 22:09:58 +0200 Subject: [PATCH 6/9] Refactor RUFH response headers to RufhResponseHeadersHandler --- .../rufh/ResumableUploadsForHttpProtocol.java | 2 + .../RufhAppendPatchRequestHandler.java | 1 - .../RufhCreationPostRequestHandler.java | 2 - .../rufh/handler/RufhHeadRequestHandler.java | 1 - .../handler/RufhOptionsRequestHandler.java | 1 - .../handler/RufhResponseHeadersHandler.java | 49 ++++++++++++++++ .../rufh/RufhProtocolCancellationTest.java | 2 + .../RufhOptionsRequestHandlerTest.java | 1 - .../RufhResponseHeadersHandlerTest.java | 57 +++++++++++++++++++ 9 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java create mode 100644 src/test/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandlerTest.java diff --git a/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java index 96ad990a..6182a18e 100644 --- a/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java +++ b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java @@ -13,6 +13,7 @@ import me.desair.tus.server.rufh.handler.RufhErrorHandler; import me.desair.tus.server.rufh.handler.RufhHeadRequestHandler; import me.desair.tus.server.rufh.handler.RufhOptionsRequestHandler; +import me.desair.tus.server.rufh.handler.RufhResponseHeadersHandler; import me.desair.tus.server.rufh.validation.RufhAppendValidator; import me.desair.tus.server.rufh.validation.RufhCreationValidator; import me.desair.tus.server.rufh.validation.RufhHeadHeaderValidator; @@ -83,6 +84,7 @@ protected void initValidators(List requestValidators) { @Override protected void initRequestHandlers(List requestHandlers) { + requestHandlers.add(new RufhResponseHeadersHandler()); requestHandlers.add(new RufhOptionsRequestHandler()); requestHandlers.add(new RufhHeadRequestHandler()); requestHandlers.add(new RufhCreationPostRequestHandler(interimResponseStrategy)); diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java index d42b2881..21c82bc5 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java @@ -74,7 +74,6 @@ public HttpProblemDetails process( uploadStorageService.update(uploadInfo); } - servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); servletResponse.setHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(uploadInfo.getOffset())); servletResponse.setHeader( HttpHeader.UPLOAD_COMPLETE, StructuredHeaderUtil.formatBoolean(isFinished)); 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 7b1494ce..3a05894c 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 @@ -89,8 +89,6 @@ && isExistingUpload(servletRequest, uploadStorageService, ownerKey)) { } } - servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); - boolean isFinished = Boolean.TRUE.equals(uploadComplete) || isUploadCompleted(uploadInfo); if (isFinished) { uploadInfo.setLength(uploadInfo.getOffset()); diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java index 9f8a005f..1bed477a 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java @@ -46,7 +46,6 @@ public HttpProblemDetails process( UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); servletResponse.setStatus(204); - servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); servletResponse.setHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(uploadInfo.getOffset())); servletResponse.setHeader( HttpHeader.UPLOAD_COMPLETE, diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java index 80731798..ec3a5183 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java @@ -44,7 +44,6 @@ public HttpProblemDetails process( servletResponse.setHeader( HttpHeader.ACCEPT_PATCH, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD + ", application/offset+octet-stream"); - servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); addUploadLimitHeader(servletResponse, uploadStorageService); servletResponse.setStatus(HttpServletResponse.SC_NO_CONTENT); diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java new file mode 100644 index 00000000..ebdc6c7c --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java @@ -0,0 +1,49 @@ +package me.desair.tus.server.rufh.handler; + +import java.io.IOException; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.rufh.HttpProblemDetails; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** + * Request handler responsible for setting common response headers that must be present in all + * Resumable Uploads for HTTP (RUFH) responses. + * + *

Specifically, sets the {@code Upload-Draft} header to {@code 11}. + * + *

Reference: Appendix B (Draft Version Identification) of + * draft-ietf-httpbis-resumable-upload-11. + */ +public class RufhResponseHeadersHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return true; + } + + @Override + public boolean isErrorHandler() { + return true; + } + + @Override + public HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + + servletResponse.setHeader(HttpHeader.UPLOAD_DRAFT, "11"); + return null; + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java index 6ef138af..dc4654f8 100644 --- a/src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.upload.UploadId; @@ -65,6 +66,7 @@ public void testUploadCancellationSuccess() throws Exception { ProtocolVersion.RUFH); assertThat(response.getStatus(), is(204)); + assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); verify(storageService).terminateUpload(info); } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java index ad9e0087..dc7821c2 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java @@ -64,7 +64,6 @@ public void testProcessOptionsRequest() throws Exception { assertThat( response.getHeader(HttpHeader.ACCEPT_PATCH), is("application/partial-upload, application/offset+octet-stream")); - assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); assertThat( response.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=100000, max-append-size=50000")); } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandlerTest.java new file mode 100644 index 00000000..a279f12b --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandlerTest.java @@ -0,0 +1,57 @@ +package me.desair.tus.server.rufh.handler; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertTrue; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** Tests for {@link RufhResponseHeadersHandler}. */ +public class RufhResponseHeadersHandlerTest { + + private RufhResponseHeadersHandler handler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @Before + public void setUp() { + handler = new RufhResponseHeadersHandler(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + public void testSupportsAllMethods() { + assertTrue(handler.supports(HttpMethod.POST)); + assertTrue(handler.supports(HttpMethod.PATCH)); + assertTrue(handler.supports(HttpMethod.OPTIONS)); + assertTrue(handler.supports(HttpMethod.HEAD)); + assertTrue(handler.supports(HttpMethod.DELETE)); + } + + @Test + public void testIsErrorHandler() { + assertTrue(handler.isErrorHandler()); + } + + @Test + public void testProcessSetsDraftHeader() throws Exception { + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + null, + null, + null, + null); + + assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); + } +} From 8c10bef07fa1f37cfabbfac90c050848295b8dc6 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 4 Jul 2026 22:18:21 +0200 Subject: [PATCH 7/9] refactor: relocate HttpProblemDetails to main me.desair.tus.server package --- .../me/desair/tus/server/{rufh => }/HttpProblemDetails.java | 3 +-- src/main/java/me/desair/tus/server/RequestHandler.java | 1 - src/main/java/me/desair/tus/server/TusExtension.java | 1 - src/main/java/me/desair/tus/server/TusFileUploadService.java | 1 - .../me/desair/tus/server/core/CorePatchRequestHandler.java | 2 +- .../tus/server/rufh/handler/RufhAppendPatchRequestHandler.java | 2 +- .../server/rufh/handler/RufhCreationPostRequestHandler.java | 2 +- .../tus/server/rufh/handler/RufhDeleteRequestHandler.java | 2 +- .../me/desair/tus/server/rufh/handler/RufhErrorHandler.java | 2 +- .../desair/tus/server/rufh/handler/RufhHeadRequestHandler.java | 2 +- .../tus/server/rufh/handler/RufhOptionsRequestHandler.java | 2 +- .../tus/server/rufh/handler/RufhResponseHeadersHandler.java | 2 +- .../java/me/desair/tus/server/util/AbstractTusExtension.java | 2 +- src/test/java/me/desair/tus/server/CoverageGapTest.java | 1 - .../desair/tus/server/{rufh => }/HttpProblemDetailsTest.java | 3 +-- .../java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java | 2 +- .../desair/tus/server/rufh/handler/RufhErrorHandlerTest.java | 2 +- 17 files changed, 13 insertions(+), 19 deletions(-) rename src/main/java/me/desair/tus/server/{rufh => }/HttpProblemDetails.java (99%) rename src/test/java/me/desair/tus/server/{rufh => }/HttpProblemDetailsTest.java (99%) diff --git a/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java b/src/main/java/me/desair/tus/server/HttpProblemDetails.java similarity index 99% rename from src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java rename to src/main/java/me/desair/tus/server/HttpProblemDetails.java index d1b1ec27..46559a8a 100644 --- a/src/main/java/me/desair/tus/server/rufh/HttpProblemDetails.java +++ b/src/main/java/me/desair/tus/server/HttpProblemDetails.java @@ -1,4 +1,4 @@ -package me.desair.tus.server.rufh; +package me.desair.tus.server; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -6,7 +6,6 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; -import me.desair.tus.server.HttpHeader; import me.desair.tus.server.util.TusServletResponse; /** diff --git a/src/main/java/me/desair/tus/server/RequestHandler.java b/src/main/java/me/desair/tus/server/RequestHandler.java index 86b3275a..a22254bc 100644 --- a/src/main/java/me/desair/tus/server/RequestHandler.java +++ b/src/main/java/me/desair/tus/server/RequestHandler.java @@ -2,7 +2,6 @@ import java.io.IOException; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.TusServletRequest; diff --git a/src/main/java/me/desair/tus/server/TusExtension.java b/src/main/java/me/desair/tus/server/TusExtension.java index 97d6dae1..e3213f71 100644 --- a/src/main/java/me/desair/tus/server/TusExtension.java +++ b/src/main/java/me/desair/tus/server/TusExtension.java @@ -4,7 +4,6 @@ import java.io.IOException; import java.util.Collection; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.TusServletRequest; diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index 294e020a..dfd3f5f4 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -17,7 +17,6 @@ import me.desair.tus.server.download.DownloadExtension; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.expiration.ExpirationExtension; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.rufh.ResumableUploadsForHttpProtocol; import me.desair.tus.server.termination.TerminationExtension; import me.desair.tus.server.upload.UploadIdFactory; diff --git a/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java b/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java index 3f33ef34..8ed264c8 100644 --- a/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/core/CorePatchRequestHandler.java @@ -6,9 +6,9 @@ import java.util.Objects; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.exception.UploadNotFoundException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java index 21c82bc5..1030c87a 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java @@ -4,8 +4,8 @@ import java.io.InputStream; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; 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 3a05894c..95144d92 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 @@ -6,8 +6,8 @@ import java.util.Map; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.rufh.InterimResponseStrategy; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java index daf330d1..e7522c84 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java @@ -2,8 +2,8 @@ import java.io.IOException; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java index 6a092055..0ef33182 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java @@ -3,11 +3,11 @@ import java.io.IOException; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.InconsistentUploadLengthException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.exception.UploadAlreadyCompletedException; import me.desair.tus.server.exception.UploadOffsetMismatchException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java index 1bed477a..c54ee9fe 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java @@ -5,8 +5,8 @@ import java.util.Map; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java index ec3a5183..dec452b5 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java @@ -6,8 +6,8 @@ import java.util.Map; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.AbstractRequestHandler; diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java index ebdc6c7c..7296a77a 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java @@ -3,8 +3,8 @@ import java.io.IOException; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.AbstractRequestHandler; diff --git a/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java b/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java index 8bf839c3..77fdc695 100644 --- a/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java +++ b/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java @@ -5,12 +5,12 @@ import java.util.LinkedList; import java.util.List; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.RequestHandler; import me.desair.tus.server.RequestValidator; import me.desair.tus.server.TusExtension; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; diff --git a/src/test/java/me/desair/tus/server/CoverageGapTest.java b/src/test/java/me/desair/tus/server/CoverageGapTest.java index 64c3c4c3..e08a73ae 100644 --- a/src/test/java/me/desair/tus/server/CoverageGapTest.java +++ b/src/test/java/me/desair/tus/server/CoverageGapTest.java @@ -8,7 +8,6 @@ import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.upload.disk.DiskStorageService; diff --git a/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java b/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java similarity index 99% rename from src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java rename to src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java index 22841a98..52735339 100644 --- a/src/test/java/me/desair/tus/server/rufh/HttpProblemDetailsTest.java +++ b/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java @@ -1,10 +1,9 @@ -package me.desair.tus.server.rufh; +package me.desair.tus.server; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import me.desair.tus.server.HttpHeader; import me.desair.tus.server.util.TusServletResponse; import org.junit.Before; import org.junit.Test; diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java index 215be752..12f71fd1 100644 --- a/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java @@ -140,7 +140,7 @@ public void testCompletedUploadFormatsProblemJson() throws Exception { request.setRequestURI("/files/test-id"); TusServletRequest tusReq = new TusServletRequest(request, true); - me.desair.tus.server.rufh.HttpProblemDetails problemDetails = + me.desair.tus.server.HttpProblemDetails problemDetails = protocol.handleError( HttpMethod.PATCH, tusReq, diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java index 5bdcb264..6a2199f7 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java @@ -8,10 +8,10 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.InconsistentUploadLengthException; import me.desair.tus.server.exception.UploadAlreadyCompletedException; import me.desair.tus.server.exception.UploadOffsetMismatchException; -import me.desair.tus.server.rufh.HttpProblemDetails; import me.desair.tus.server.upload.UploadId; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; From 0e9de914df082f7ecb05c5441e680f52ac3d71c3 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Tue, 7 Jul 2026 08:35:21 +0200 Subject: [PATCH 8/9] Implement RFC 9530 HTTP Digests Extension for RUFH with deduplication index base64-to-hex storage in DiskStorageService --- README.md | 6 + docs/MIGRATION.md | 7 + .../java/me/desair/tus/server/HttpHeader.java | 18 + .../desair/tus/server/HttpProblemDetails.java | 17 + .../me/desair/tus/server/TusExtension.java | 12 + .../tus/server/TusFileUploadService.java | 5 +- .../server/checksum/ChecksumAlgorithm.java | 162 ++++++++- .../checksum/ChecksumPatchRequestHandler.java | 4 +- .../core/validation/HttpMethodValidator.java | 5 +- .../server/digest/HttpDigestsExtension.java | 47 +++ .../HttpDigestsOptionsRequestHandler.java | 39 +++ ...HttpDigestsPostPutPatchRequestHandler.java | 227 +++++++++++++ .../validation/HttpDigestsValidator.java | 95 ++++++ .../UploadDigestMismatchException.java | 11 + .../rufh/ResumableUploadsForHttpProtocol.java | 5 + .../server/rufh/handler/RufhErrorHandler.java | 4 + .../desair/tus/server/upload/UploadInfo.java | 42 +++ .../upload/disk/DiskStorageService.java | 25 +- .../tus/server/util/StructuredHeaderUtil.java | 21 ++ .../tus/server/util/TusServletRequest.java | 14 +- .../java/me/desair/tus/server/util/Utils.java | 19 ++ .../tus/server/ITTusFileUploadService.java | 5 +- .../checksum/ChecksumAlgorithmTest.java | 122 +++++++ .../validation/HttpMethodValidatorTest.java | 15 + .../HttpDigestsOptionsRequestHandlerTest.java | 58 ++++ ...DigestsPostPutPatchRequestHandlerTest.java | 165 +++++++++ .../validation/HttpDigestsValidatorTest.java | 104 ++++++ .../server/rufh/HttpDigestsProtocolTest.java | 318 ++++++++++++++++++ .../ResumableUploadsForHttpProtocolTest.java | 9 + .../upload/disk/DiskStorageServiceTest.java | 52 ++- .../server/util/StructuredHeaderUtilTest.java | 14 + .../me/desair/tus/server/util/UtilsTest.java | 18 + 32 files changed, 1635 insertions(+), 30 deletions(-) create mode 100644 src/main/java/me/desair/tus/server/digest/HttpDigestsExtension.java create mode 100644 src/main/java/me/desair/tus/server/digest/HttpDigestsOptionsRequestHandler.java create mode 100644 src/main/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandler.java create mode 100644 src/main/java/me/desair/tus/server/digest/validation/HttpDigestsValidator.java create mode 100644 src/main/java/me/desair/tus/server/exception/UploadDigestMismatchException.java create mode 100644 src/test/java/me/desair/tus/server/digest/HttpDigestsOptionsRequestHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/digest/validation/HttpDigestsValidatorTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/HttpDigestsProtocolTest.java diff --git a/README.md b/README.md index a9f0d354..c191e9dd 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ You can configure protocol support via `withSupportedProtocolVersions(ProtocolVe | **Expiration Handling** | Supported (`Expiration` extension) | Supported (`Expiration` extension) | | **Concatenation** | Supported (`Concatenation` extension) | Supported (`Concatenation` extension) | | **Download Extension** | Supported (`Download` extension) | Supported (`Download` extension) | +| **HTTP Digests Validation** | N/A | Supported (`http-digests` extension) | ## Tus Protocol Extensions Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-protocol), the library has all optional tus protocol extensions enabled by default. This means that the `Tus-Extension` header has value `creation,creation-defer-length,checksum,checksum-trailer,termination,expiration,concatenation,concatenation-unfinished`. Optionally you can also enable an unofficial `download` extension (see [configuration section](#usage-and-configuration)). @@ -68,6 +69,7 @@ Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core- * [concatenation](https://tus.io/protocols/resumable-upload.html#concatenation): This extension can be used to concatenate multiple uploads into a single final upload enabling clients to perform parallel uploads and to upload non-contiguous chunks. * [concatenation-unfinished](https://tus.io/protocols/resumable-upload.html#concatenation): The client is allowed send the request to concatenate partial uploads while these partial uploads are still in progress. * `download`: The (unofficial) download extension allows clients to download uploaded files using a HTTP `GET` request. You can enable this extension by calling the `withDownloadFeature()` method. +* `http-digests`: An extension implementing RFC 9530 to verify data integrity for the Resumable Uploads for HTTP (RUFH) protocol. Supported headers include `Content-Digest`, `Repr-Digest`, `Want-Content-Digest`, and `Want-Repr-Digest`. ## Usage and Configuration @@ -88,6 +90,10 @@ The first step is to create a `TusFileUploadService` object using its constructo * `disableTusExtension(String)`: Disable the `TusExtension` for which the `getName()` method matches the provided string. The default extensions have names "creation", "checksum", "expiration", "concatenation", "termination" and "download". You cannot disable the "core" feature. * `withUploadIdFactory(UploadIdFactory)`: Provide a custom `UploadIdFactory` implementation that should be used to generate identifiers for the different uploads. The default implementation generates identifiers using a UUID (`UuidUploadIdFactory`). Another example implementation of a custom ID factory is the system-time based `TimeBasedUploadIdFactory` class. +### HTTP Digests ([RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html)) +The `http-digests` extension implements RFC 9530 to support data integrity checks for both individual data chunks (`Content-Digest`) and the entire file (`Repr-Digest`). +* **Performance Disclaimer**: Calculating representation digests (`Repr-Digest`) requires streaming the entire uploaded file from disk. For extremely large files, this can introduce non-trivial I/O performance overhead on the server. To optimize, only request it via `Want-Repr-Digest` when absolutely necessary. + For now this library only provides filesystem based storage and locking options. You can however provide your own implementation of a `UploadStorageService` and `UploadLockingService` using the methods `withUploadStorageService(UploadStorageService)` and `withUploadLockingService(UploadLockingService)` in order to support different types of upload storage. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index c97e8122..5f214edd 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -53,6 +53,7 @@ When updating clients or proxies to use IETF Resumable Uploads, note the followi | **Upload Limits** | `Tus-Max-Size: 104857600` | `Upload-Limit: max-size=104857600, max-append-size=5242880` | | **Capabilities Discovery** | `OPTIONS` returns `Tus-Version`, `Tus-Extension` | `OPTIONS` returns `Accept-Patch: application/partial-upload` and `Upload-Limit` | | **Error Format** | Plain text | `application/problem+json` (RFC 7807) | +| **Checksum / Data Integrity** | `Upload-Checksum: sha1 ...` | `Content-Digest` and `Repr-Digest` (RFC 9530) | --- @@ -121,6 +122,12 @@ IETF Resumable Upload error responses return RFC 7807 problem details JSON: } ``` +### 4.4 Data Integrity Verification (HTTP Digests) +In Tus 1.0.0, data integrity was verified using the `Upload-Checksum` header from the checksum extension. +In RUFH protocol, integrity verification is achieved using **RFC 9530 HTTP Digests**: +- **Chunk verification**: Use the `Content-Digest` header containing the cryptographic hash of the transmitted chunk (e.g. `Content-Digest: sha-256=:...:`). +- **Full file verification**: Use the `Repr-Digest` header in the creation request or final append request to define the expected digest of the complete file. Alternatively, send `Want-Repr-Digest` in the request to receive the calculated file digest from the server in the response's `Repr-Digest` header. + --- ## 5. Reverse Proxies & Load Balancers diff --git a/src/main/java/me/desair/tus/server/HttpHeader.java b/src/main/java/me/desair/tus/server/HttpHeader.java index 75fc8ccc..4c91145d 100644 --- a/src/main/java/me/desair/tus/server/HttpHeader.java +++ b/src/main/java/me/desair/tus/server/HttpHeader.java @@ -128,6 +128,24 @@ public class HttpHeader { /** Media type application/problem+json used for RFC 7807 problem details error responses. */ public static final String CONTENT_TYPE_PROBLEM_JSON = "application/problem+json"; + /** The Content-Digest HTTP header field associates one or more digests with a message content. */ + public static final String CONTENT_DIGEST = "Content-Digest"; + + /** The Repr-Digest HTTP header field associates one or more digests with a representation. */ + public static final String REPR_DIGEST = "Repr-Digest"; + + /** + * The Want-Content-Digest response/request header indicates capabilities/preference for content + * digests. + */ + public static final String WANT_CONTENT_DIGEST = "Want-Content-Digest"; + + /** + * The Want-Repr-Digest response/request header indicates capabilities/preference for + * representation digests. + */ + public static final String WANT_REPR_DIGEST = "Want-Repr-Digest"; + private HttpHeader() { // This is an utility class to hold constants } diff --git a/src/main/java/me/desair/tus/server/HttpProblemDetails.java b/src/main/java/me/desair/tus/server/HttpProblemDetails.java index 46559a8a..658aca6b 100644 --- a/src/main/java/me/desair/tus/server/HttpProblemDetails.java +++ b/src/main/java/me/desair/tus/server/HttpProblemDetails.java @@ -117,6 +117,23 @@ public static HttpProblemDetails forInconsistentLength() { null); } + /** + * Create a Mismatched Digest Values (400 Bad Request) problem details response. + * + *

Reference: Section 4 of RFC 9530: "This section defines the + * 'https://iana.org/assignments/http-problem-types#digest-mismatched-values' problem type." + * + * @return HttpProblemDetails instance configured for digest mismatch + */ + public static HttpProblemDetails forDigestMismatch() { + return new HttpProblemDetails( + HttpServletResponse.SC_BAD_REQUEST, + "https://iana.org/assignments/http-problem-types#digest-mismatched-values", + "Mismatched Digest Values", + "The calculated digest does not match the provided digest value.", + null); + } + /** * Send problem details response directly to a TusServletResponse. * diff --git a/src/main/java/me/desair/tus/server/TusExtension.java b/src/main/java/me/desair/tus/server/TusExtension.java index e3213f71..d69dac8a 100644 --- a/src/main/java/me/desair/tus/server/TusExtension.java +++ b/src/main/java/me/desair/tus/server/TusExtension.java @@ -167,6 +167,18 @@ default HttpProblemDetails handleError( return null; } + /** + * Returns whether this extension must handle errors even if the request was already successfully + * processed by it. + * + * @param method The HTTP method + * @param version The protocol version + * @return true if the extension must handle errors on exception, false otherwise + */ + default boolean mustReprocessOnError(HttpMethod method, ProtocolVersion version) { + return false; + } + /** * The minimal list of HTTP methods that this extension needs to function properly. * diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index dfd3f5f4..4f3bed1c 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -14,6 +14,7 @@ import me.desair.tus.server.concatenation.ConcatenationExtension; import me.desair.tus.server.core.CoreProtocol; import me.desair.tus.server.creation.CreationExtension; +import me.desair.tus.server.digest.HttpDigestsExtension; import me.desair.tus.server.download.DownloadExtension; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.expiration.ExpirationExtension; @@ -69,6 +70,7 @@ protected void initFeatures() { addTusExtension(new ExpirationExtension()); addTusExtension(new ConcatenationExtension()); addTusExtension(new ResumableUploadsForHttpProtocol()); + addTusExtension(new HttpDigestsExtension()); } /** @@ -528,6 +530,7 @@ public ProtocolVersion detectProtocolVersion(HttpServletRequest request) { return ProtocolVersion.RUFH; } + // We're in AUTO mode, so we need to detect the protocol version based on the request headers if (request != null) { if (request.getHeader(HttpHeader.TUS_RESUMABLE) != null) { return ProtocolVersion.TUS_1_0_0; @@ -603,7 +606,7 @@ protected void processTusException( HttpProblemDetails problemDetails = null; try { for (TusExtension feature : enabledFeatures.values()) { - if (!request.isProcessedBy(feature)) { + if (!request.isProcessedBy(feature) || feature.mustReprocessOnError(method, version)) { request.addProcessor(feature); HttpProblemDetails pd = feature.handleError( diff --git a/src/main/java/me/desair/tus/server/checksum/ChecksumAlgorithm.java b/src/main/java/me/desair/tus/server/checksum/ChecksumAlgorithm.java index ea6ddf1a..c6dc856d 100644 --- a/src/main/java/me/desair/tus/server/checksum/ChecksumAlgorithm.java +++ b/src/main/java/me/desair/tus/server/checksum/ChecksumAlgorithm.java @@ -2,6 +2,12 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import me.desair.tus.server.util.StructuredHeaderUtil; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,22 +17,26 @@ * only consist of ASCII characters with the modification that uppercase characters are excluded. */ public enum ChecksumAlgorithm { - MD5("MD5", "md5"), - SHA1("SHA-1", "sha1"), - SHA256("SHA-256", "sha256"), - SHA384("SHA-384", "sha384"), - SHA512("SHA-512", "sha512"); + MD5("MD5", "md5", new String[] {"md5"}, 1), + SHA1("SHA-1", "sha1", new String[] {"sha", "sha1", "sha-1"}, 2), + SHA256("SHA-256", "sha256", new String[] {"sha-256"}, 5), + SHA384("SHA-384", "sha384", new String[] {"sha-384"}, 3), + SHA512("SHA-512", "sha512", new String[] {"sha-512"}, 4); public static final String CHECKSUM_VALUE_SEPARATOR = " "; private static final Logger log = LoggerFactory.getLogger(ChecksumAlgorithm.class); - private String javaName; - private String tusName; + private final String javaName; + private final String tusName; + private final List httpDigestNames; + private final int priority; - ChecksumAlgorithm(String javaName, String tusName) { + ChecksumAlgorithm(String javaName, String tusName, String[] httpDigestNames, int priority) { this.javaName = javaName; this.tusName = tusName; + this.httpDigestNames = Collections.unmodifiableList(Arrays.asList(httpDigestNames)); + this.priority = priority; } public String getJavaName() { @@ -37,6 +47,14 @@ public String getTusName() { return tusName; } + public List getHttpDigestNames() { + return httpDigestNames; + } + + public int getPriority() { + return priority; + } + @Override public String toString() { return getTusName(); @@ -64,4 +82,132 @@ public static ChecksumAlgorithm forUploadChecksumHeader(String uploadChecksumHea String algorithm = StringUtils.substringBefore(uploadChecksumHeader, CHECKSUM_VALUE_SEPARATOR); return forTusName(algorithm); } + + /** + * Retrieves the {@link ChecksumAlgorithm} corresponding to a given RFC 9530 HTTP digest algorithm + * name (case-insensitive, with whitespace trimmed). + * + * @param name The HTTP digest algorithm name + * @return The matching ChecksumAlgorithm, or null if none matches + */ + public static ChecksumAlgorithm forHttpDigestName(String name) { + if (name == null) { + return null; + } + String normalized = name.trim().toLowerCase(); + for (ChecksumAlgorithm alg : ChecksumAlgorithm.values()) { + for (String digestName : alg.getHttpDigestNames()) { + if (digestName.equals(normalized)) { + return alg; + } + } + } + return null; + } + + /** + * Returns a comma-separated string of the supported HTTP digest algorithms for use in the + * Want-Repr-Digest options header. + * + * @return The comma-separated list of digest algorithms + */ + public static String getSupportedHttpDigestAlgorithmsHeaderValue() { + List sorted = new java.util.ArrayList<>(Arrays.asList(values())); + sorted.sort((a, b) -> Integer.compare(b.getPriority(), a.getPriority())); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < sorted.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(sorted.get(i).getHttpDigestNames().get(0)); + } + return sb.toString(); + } + + /** + * Cleans an RFC 9530 structured digest value by stripping surrounding colons. + * + * @param val The raw base64 digest string value, possibly colon-wrapped + * @return The cleaned base64 digest string + */ + public static String cleanDigestValue(String val) { + if (val == null) { + return null; + } + String s = val.trim(); + if (s.startsWith(":")) { + s = s.substring(1); + } + if (s.endsWith(":")) { + s = s.substring(0, s.length() - 1); + } + return s; + } + + /** + * Selects the best supported ChecksumAlgorithm from the Want-Repr-Digest header value, taking + * weight preferences (q) into account. + * + * @param wantReprDigest The Want-Repr-Digest header value + * @return The highest-preference supported algorithm, or null if none matches or wanted + */ + public static ChecksumAlgorithm selectBestAlgorithm(String wantReprDigest) { + if (StringUtils.isBlank(wantReprDigest)) { + return null; + } + + java.util.List items = StructuredHeaderUtil.parseList(wantReprDigest); + double bestQ = -1.0; + ChecksumAlgorithm bestAlg = null; + + for (String item : items) { + String token = StringUtils.substringBefore(item, ";").trim(); + ChecksumAlgorithm alg = forHttpDigestName(token); + if (alg != null) { + double q = 1.0; + if (item.contains(";")) { + String paramPart = StringUtils.substringAfter(item, ";").trim(); + if (paramPart.startsWith("q=")) { + try { + q = Double.parseDouble(paramPart.substring(2).trim()); + } catch (NumberFormatException e) { + // Ignore, default to 1.0 + } + } + } + + if (q > 0.0) { + if (q > bestQ) { + bestQ = q; + bestAlg = alg; + } else if (Math.abs(q - bestQ) < 1e-9 && alg.getPriority() > bestAlg.getPriority()) { + bestAlg = alg; + } + } + } + } + + return bestAlg; + } + + /** + * Parses an RFC 9530 structured digest header value into a map of ChecksumAlgorithm to their + * cleaned digest values. + * + * @param headerValue The raw structured digest header value + * @return A map of mapped ChecksumAlgorithm to cleaned digest values + */ + public static Map parseDigestHeader(String headerValue) { + Map result = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(headerValue)) { + Map digestDict = StructuredHeaderUtil.parseDictionary(headerValue); + for (Map.Entry entry : digestDict.entrySet()) { + ChecksumAlgorithm alg = forHttpDigestName(entry.getKey()); + if (alg != null) { + result.put(alg, cleanDigestValue((String) entry.getValue())); + } + } + } + return result; + } } diff --git a/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java b/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java index 767fa519..d9595a4e 100644 --- a/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java @@ -36,9 +36,7 @@ public void process( if (servletRequest.hasCalculatedChecksum() && StringUtils.isNotBlank(uploadChecksumHeader)) { // The Upload-Checksum header can be a trailing header which is only present after - // reading the - // full content. - // Therefor we need to revalidate that header here + // reading the full content.Therefor we need to revalidate that header here. new ChecksumAlgorithmValidator() .validate(method, servletRequest, uploadStorageService, ownerKey); diff --git a/src/main/java/me/desair/tus/server/core/validation/HttpMethodValidator.java b/src/main/java/me/desair/tus/server/core/validation/HttpMethodValidator.java index e12d7f50..d8253453 100644 --- a/src/main/java/me/desair/tus/server/core/validation/HttpMethodValidator.java +++ b/src/main/java/me/desair/tus/server/core/validation/HttpMethodValidator.java @@ -18,7 +18,10 @@ public void validate( String ownerKey) throws TusException { - if (method == null) { + if (method == null + || HttpMethod.PUT.equals(method) + || HttpMethod.TRACE.equals(method) + || HttpMethod.CONNECT.equals(method)) { throw new UnsupportedMethodException( "The HTTP method " + request.getMethod() + " is not supported"); } diff --git a/src/main/java/me/desair/tus/server/digest/HttpDigestsExtension.java b/src/main/java/me/desair/tus/server/digest/HttpDigestsExtension.java new file mode 100644 index 00000000..c1adbdac --- /dev/null +++ b/src/main/java/me/desair/tus/server/digest/HttpDigestsExtension.java @@ -0,0 +1,47 @@ +package me.desair.tus.server.digest; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.RequestHandler; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.digest.validation.HttpDigestsValidator; +import me.desair.tus.server.util.AbstractTusExtension; + +/** + * Protocol extension implementing the RFC 9530 HTTP Digests for integrity verification. Supported + * on RUFH protocol version. + */ +public class HttpDigestsExtension extends AbstractTusExtension { + + @Override + public String getName() { + return "http-digests"; + } + + @Override + public Collection getMinimalSupportedHttpMethods() { + return Arrays.asList(HttpMethod.OPTIONS, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH); + } + + @Override + public boolean isApplicable(HttpMethod method, ProtocolVersion version) { + if (HttpMethod.OPTIONS.equals(method)) { + return true; + } + return version == ProtocolVersion.RUFH; + } + + @Override + protected void initValidators(List requestValidators) { + requestValidators.add(new HttpDigestsValidator()); + } + + @Override + protected void initRequestHandlers(List requestHandlers) { + requestHandlers.add(new HttpDigestsOptionsRequestHandler()); + requestHandlers.add(new HttpDigestsPostPutPatchRequestHandler()); + } +} diff --git a/src/main/java/me/desair/tus/server/digest/HttpDigestsOptionsRequestHandler.java b/src/main/java/me/desair/tus/server/digest/HttpDigestsOptionsRequestHandler.java new file mode 100644 index 00000000..1619ff34 --- /dev/null +++ b/src/main/java/me/desair/tus/server/digest/HttpDigestsOptionsRequestHandler.java @@ -0,0 +1,39 @@ +package me.desair.tus.server.digest; + +import java.io.IOException; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; +import me.desair.tus.server.checksum.ChecksumAlgorithm; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** Request handler to set option headers for RFC 9530 HTTP Digests. */ +public class HttpDigestsOptionsRequestHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.OPTIONS.equals(method); + } + + @Override + public HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + + String supportedAlgs = ChecksumAlgorithm.getSupportedHttpDigestAlgorithmsHeaderValue(); + servletResponse.setHeader(HttpHeader.WANT_CONTENT_DIGEST, supportedAlgs); + servletResponse.setHeader(HttpHeader.WANT_REPR_DIGEST, supportedAlgs); + return null; + } +} diff --git a/src/main/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandler.java b/src/main/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandler.java new file mode 100644 index 00000000..5af9add6 --- /dev/null +++ b/src/main/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandler.java @@ -0,0 +1,227 @@ +package me.desair.tus.server.digest; + +import java.io.IOException; +import java.io.InputStream; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.util.Map; +import java.util.Objects; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.HttpProblemDetails; +import me.desair.tus.server.checksum.ChecksumAlgorithm; +import me.desair.tus.server.digest.validation.HttpDigestsValidator; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UploadDigestMismatchException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +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.codec.binary.Base64; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +/** Unified request handler for RFC 9530 HTTP Digests verification in RUFH protocol. */ +public class HttpDigestsPostPutPatchRequestHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.POST.equals(method) + || HttpMethod.PUT.equals(method) + || HttpMethod.PATCH.equals(method); + } + + @Override + public HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + + // 1. Verify Content-Digest for the request chunk + verifyContentDigest(method, servletRequest); + + // Resolve UploadInfo using centralized helpers + String uploadUri = Utils.getUploadUri(method, servletRequest, servletResponse); + UploadInfo uploadInfo = null; + if (uploadUri != null) { + try { + uploadInfo = uploadStorageService.getUploadInfo(uploadUri, ownerKey); + } catch (Exception e) { + // Ignored + } + } + + if (uploadInfo != null) { + // 2. Capture client Repr-Digest and Want-Repr-Digest + captureDigestPreferences(servletRequest, uploadInfo, uploadStorageService); + + // 3. Verify Repr-Digest on upload completion + verifyReprDigestOnCompletion(uploadInfo, uploadUri, ownerKey, uploadStorageService); + + // 4. Provide Repr-Digest response header if requested + addReprDigestResponseHeader( + servletResponse, uploadInfo, uploadUri, ownerKey, uploadStorageService); + } + + return null; + } + + private void verifyContentDigest(HttpMethod method, TusServletRequest servletRequest) + throws TusException, IOException { + String headerVal = servletRequest.getHeader(HttpHeader.CONTENT_DIGEST); + if (StringUtils.isBlank(headerVal)) { + return; + } + + // Revalidate trailing header syntax + new HttpDigestsValidator().validate(method, servletRequest, null, null); + + Map expectedDigests = ChecksumAlgorithm.parseDigestHeader(headerVal); + for (Map.Entry entry : expectedDigests.entrySet()) { + ChecksumAlgorithm alg = entry.getKey(); + String expectedValue = entry.getValue(); + String calculatedValue = servletRequest.getCalculatedChecksum(alg); + + if (calculatedValue != null && !Strings.CS.equals(expectedValue, calculatedValue)) { + throw new UploadDigestMismatchException( + "Content-Digest mismatch for algorithm " + + alg.getHttpDigestNames().get(0) + + ". Expected: " + + expectedValue + + " but was: " + + calculatedValue); + } + } + } + + private void captureDigestPreferences( + TusServletRequest servletRequest, + UploadInfo uploadInfo, + UploadStorageService uploadStorageService) + throws IOException, TusException { + boolean updated = false; + + // Capture Repr-Digest + String reprDigestHeader = servletRequest.getHeader(HttpHeader.REPR_DIGEST); + if (StringUtils.isNotBlank(reprDigestHeader) && uploadInfo.getRepresentationDigest() == null) { + uploadInfo.setRepresentationDigest(reprDigestHeader); + updated = true; + } + + // Capture Want-Repr-Digest + String wantReprDigestHeader = servletRequest.getHeader(HttpHeader.WANT_REPR_DIGEST); + if (StringUtils.isNotBlank(wantReprDigestHeader) + && uploadInfo.getRequestedRepresentationDigests() == null) { + uploadInfo.setRequestedRepresentationDigests(wantReprDigestHeader); + updated = true; + } + + if (updated) { + uploadStorageService.update(uploadInfo); + } + } + + private void verifyReprDigestOnCompletion( + UploadInfo uploadInfo, + String uploadUri, + String ownerKey, + UploadStorageService uploadStorageService) + throws IOException, TusException { + + if (!uploadInfo.isUploadInProgress() && uploadInfo.getRepresentationDigest() != null) { + Map expectedDigests = + ChecksumAlgorithm.parseDigestHeader(uploadInfo.getRepresentationDigest()); + + for (Map.Entry entry : expectedDigests.entrySet()) { + ChecksumAlgorithm alg = entry.getKey(); + String expectedValue = entry.getValue(); + String calculatedValue = + calculateEntireFileDigest(uploadUri, ownerKey, alg, uploadStorageService); + + if (calculatedValue != null) { + if (!Strings.CS.equals(expectedValue, calculatedValue)) { + throw new UploadDigestMismatchException( + "Repr-Digest mismatch for algorithm " + + alg.getHttpDigestNames().get(0) + + ". Expected: " + + expectedValue + + " but was: " + + calculatedValue); + } else { + // Deduplication support: If verification succeeds and deduplication is enabled + if (uploadStorageService.isUploadDeduplicationEnabled() + && uploadInfo.getDuplicatesUploadId() == null) { + UploadInfo duplicateInfo = + uploadStorageService.getUploadInfoByChecksum(expectedValue, alg); + if (duplicateInfo != null + && !Objects.equals(duplicateInfo.getId(), uploadInfo.getId())) { + uploadInfo.setDuplicatesUploadId(duplicateInfo.getId()); + uploadStorageService.update(uploadInfo); + } else { + // Index this file + uploadInfo.setChecksum(expectedValue); + uploadInfo.setChecksumAlgorithm(alg); + uploadStorageService.update(uploadInfo); + } + } + } + } + } + } + } + + private void addReprDigestResponseHeader( + TusServletResponse servletResponse, + UploadInfo uploadInfo, + String uploadUri, + String ownerKey, + UploadStorageService uploadStorageService) + throws IOException, TusException { + + String requestedDigests = uploadInfo.getRequestedRepresentationDigests(); + if (StringUtils.isNotBlank(requestedDigests)) { + ChecksumAlgorithm preferredAlg = ChecksumAlgorithm.selectBestAlgorithm(requestedDigests); + if (preferredAlg != null) { + String calculatedValue = + calculateEntireFileDigest(uploadUri, ownerKey, preferredAlg, uploadStorageService); + if (calculatedValue != null) { + servletResponse.setHeader( + HttpHeader.REPR_DIGEST, + preferredAlg.getHttpDigestNames().get(0) + "=:" + calculatedValue + ":"); + } + } + } + } + + private String calculateEntireFileDigest( + String uploadUri, + String ownerKey, + ChecksumAlgorithm alg, + UploadStorageService uploadStorageService) + throws IOException, TusException { + MessageDigest md = alg.getMessageDigest(); + if (md == null) { + return null; + } + try (InputStream is = uploadStorageService.getUploadedBytes(uploadUri, ownerKey)) { + if (is == null) { + return null; + } + try (DigestInputStream dis = new DigestInputStream(is, md)) { + byte[] buffer = new byte[8192]; + while (dis.read(buffer) != -1) { + // do nothing, let stream update digest + } + } + } + return Base64.encodeBase64String(md.digest()); + } +} diff --git a/src/main/java/me/desair/tus/server/digest/validation/HttpDigestsValidator.java b/src/main/java/me/desair/tus/server/digest/validation/HttpDigestsValidator.java new file mode 100644 index 00000000..8d50d2e6 --- /dev/null +++ b/src/main/java/me/desair/tus/server/digest/validation/HttpDigestsValidator.java @@ -0,0 +1,95 @@ +package me.desair.tus.server.digest.validation; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.checksum.ChecksumAlgorithm; +import me.desair.tus.server.exception.ChecksumAlgorithmNotSupportedException; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.StructuredHeaderUtil; +import org.apache.commons.lang3.StringUtils; + +/** Validates HTTP Digest headers (Content-Digest, Repr-Digest, Want-Repr-Digest) structure. */ +public class HttpDigestsValidator implements RequestValidator { + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException, IOException { + + try { + String contentDigest = request.getHeader(HttpHeader.CONTENT_DIGEST); + if (StringUtils.isNotBlank(contentDigest)) { + Map digestDict = StructuredHeaderUtil.parseDictionary(contentDigest); + if (digestDict.isEmpty()) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, "Content-Digest cannot be empty"); + } + for (String key : digestDict.keySet()) { + if (ChecksumAlgorithm.forHttpDigestName(key) == null) { + throw new ChecksumAlgorithmNotSupportedException( + "The " + + HttpHeader.CONTENT_DIGEST + + " header value contains unsupported algorithm: " + + key); + } + } + } + + String reprDigest = request.getHeader(HttpHeader.REPR_DIGEST); + if (StringUtils.isNotBlank(reprDigest)) { + Map digestDict = StructuredHeaderUtil.parseDictionary(reprDigest); + if (digestDict.isEmpty()) { + throw new TusException(HttpServletResponse.SC_BAD_REQUEST, "Repr-Digest cannot be empty"); + } + for (String key : digestDict.keySet()) { + if (ChecksumAlgorithm.forHttpDigestName(key) == null) { + throw new ChecksumAlgorithmNotSupportedException( + "The " + + HttpHeader.REPR_DIGEST + + " header value contains unsupported algorithm: " + + key); + } + } + } + + String wantReprDigest = request.getHeader(HttpHeader.WANT_REPR_DIGEST); + if (StringUtils.isNotBlank(wantReprDigest)) { + java.util.List items = StructuredHeaderUtil.parseList(wantReprDigest); + if (items.isEmpty()) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, "Want-Repr-Digest cannot be empty"); + } + for (String item : items) { + String token = StringUtils.substringBefore(item, ";").trim(); + if (!token.matches("^[a-zA-Z0-9_*./-]+$")) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, + "Invalid token format in Want-Repr-Digest: " + token); + } + } + } + } catch (TusException te) { + throw te; + } catch (Exception e) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, + "Invalid structured header format: " + e.getMessage()); + } + } + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.POST.equals(method) + || HttpMethod.PUT.equals(method) + || HttpMethod.PATCH.equals(method); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/UploadDigestMismatchException.java b/src/main/java/me/desair/tus/server/exception/UploadDigestMismatchException.java new file mode 100644 index 00000000..bb2f0b95 --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/UploadDigestMismatchException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +/** + * Exception thrown when the client provided digest does not match the digest calculated by the + * server, in compliance with RFC 9530. + */ +public class UploadDigestMismatchException extends TusException { + public UploadDigestMismatchException(String message) { + super(400, message); + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java index 6182a18e..09194968 100644 --- a/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java +++ b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java @@ -73,6 +73,11 @@ public boolean isApplicable(HttpMethod method, ProtocolVersion version) { return version == ProtocolVersion.RUFH; } + @Override + public boolean mustReprocessOnError(HttpMethod method, ProtocolVersion version) { + return version == ProtocolVersion.RUFH; + } + @Override protected void initValidators(List requestValidators) { requestValidators.add(new RufhSafePathValidator()); diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java index 0ef33182..8848b291 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java @@ -7,6 +7,7 @@ import me.desair.tus.server.exception.InconsistentUploadLengthException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.exception.UploadAlreadyCompletedException; +import me.desair.tus.server.exception.UploadDigestMismatchException; import me.desair.tus.server.exception.UploadOffsetMismatchException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; @@ -73,6 +74,9 @@ public HttpProblemDetails process( } else if (exception instanceof InconsistentUploadLengthException) { // Section 7.3: Inconsistent Length return HttpProblemDetails.forInconsistentLength(); + } else if (exception instanceof UploadDigestMismatchException) { + // RFC 9530 Mismatched Digest Values + return HttpProblemDetails.forDigestMismatch(); } return null; } diff --git a/src/main/java/me/desair/tus/server/upload/UploadInfo.java b/src/main/java/me/desair/tus/server/upload/UploadInfo.java index 4076950f..6cfbdda7 100644 --- a/src/main/java/me/desair/tus/server/upload/UploadInfo.java +++ b/src/main/java/me/desair/tus/server/upload/UploadInfo.java @@ -40,6 +40,8 @@ public class UploadInfo implements Serializable { private UploadId duplicatesUploadId; private String checksum; private ChecksumAlgorithm checksumAlgorithm; + private String representationDigest; + private String requestedRepresentationDigests; /** Default constructor to use if an upload is created without HTTP request. */ public UploadInfo() { @@ -401,6 +403,42 @@ public void setChecksumAlgorithm(ChecksumAlgorithm checksumAlgorithm) { this.checksumAlgorithm = checksumAlgorithm; } + /** + * Get the expected representation digest of the complete upload. + * + * @return The representation digest + */ + public String getRepresentationDigest() { + return representationDigest; + } + + /** + * Set the expected representation digest of the complete upload. + * + * @param representationDigest The representation digest + */ + public void setRepresentationDigest(String representationDigest) { + this.representationDigest = representationDigest; + } + + /** + * Get the requested representation digests header value from the client. + * + * @return The requested representation digests header value + */ + public String getRequestedRepresentationDigests() { + return requestedRepresentationDigests; + } + + /** + * Set the requested representation digests header value. + * + * @param requestedRepresentationDigests The requested representation digests header value + */ + public void setRequestedRepresentationDigests(String requestedRepresentationDigests) { + this.requestedRepresentationDigests = requestedRepresentationDigests; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -427,6 +465,8 @@ public boolean equals(Object o) { .append(getDuplicatesUploadId(), that.getDuplicatesUploadId()) .append(getChecksum(), that.getChecksum()) .append(getChecksumAlgorithm(), that.getChecksumAlgorithm()) + .append(getRepresentationDigest(), that.getRepresentationDigest()) + .append(getRequestedRepresentationDigests(), that.getRequestedRepresentationDigests()) .isEquals(); } @@ -446,6 +486,8 @@ public int hashCode() { .append(getDuplicatesUploadId()) .append(getChecksum()) .append(getChecksumAlgorithm()) + .append(getRepresentationDigest()) + .append(getRequestedRepresentationDigests()) .toHashCode(); } 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 e5c6977f..73ed1881 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 @@ -31,6 +31,7 @@ import me.desair.tus.server.upload.concatenation.UploadConcatenationService; import me.desair.tus.server.upload.concatenation.VirtualConcatenationService; import me.desair.tus.server.util.Utils; +import org.apache.commons.codec.binary.Base64; import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -464,17 +465,35 @@ private Path getInfoPath(UploadId id) throws UploadNotFoundException { } private Path getChecksumPath(String checksum, ChecksumAlgorithm algorithm) throws IOException { - if (!isSafePathComponent(checksum)) { + String filename = checksum; + if (filename != null) { + if (!filename.matches("^[0-9a-fA-F]+$")) { + try { + byte[] bytes = Base64.decodeBase64(filename); + if (bytes != null && bytes.length > 0) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + filename = sb.toString(); + } + } catch (Exception e) { + // Ignore, keep original filename + } + } + } + + if (!isSafePathComponent(filename)) { throw new IOException("The checksum contains an unsafe value."); } - if (algorithm == null || !isSafePathComponent(algorithm.toString())) { + if (!isSafePathComponent(algorithm.toString())) { throw new IOException("The checksum algorithm contains an unsafe value."); } return getStoragePath() .getParent() .resolve("checksums") .resolve(algorithm.toString()) - .resolve(checksum); + .resolve(filename); } private boolean isSafePathComponent(String component) { diff --git a/src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java b/src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java index f82c7f31..a531a261 100644 --- a/src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java +++ b/src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java @@ -166,4 +166,25 @@ public static String sanitizeHeaderValue(String value) { } return value.replace("\r", "").replace("\n", "").replace("\0", "").trim(); } + + /** + * Parse a Structured Header List according to RFC 9651 Section 3.1. + * + * @param headerValue The list header value string + * @return Ordered {@link java.util.List} of member item strings + */ + public static java.util.List parseList(String headerValue) { + java.util.List list = new java.util.ArrayList<>(); + if (headerValue == null || headerValue.isBlank()) { + return list; + } + String[] members = headerValue.split(","); + for (String member : members) { + String trimmedMember = member.trim(); + if (!trimmedMember.isEmpty()) { + list.add(sanitizeHeaderValue(trimmedMember)); + } + } + return list; + } } 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 699b384a..679f999b 100644 --- a/src/main/java/me/desair/tus/server/util/TusServletRequest.java +++ b/src/main/java/me/desair/tus/server/util/TusServletRequest.java @@ -7,7 +7,6 @@ import java.security.DigestInputStream; import java.security.MessageDigest; import java.util.Arrays; -import java.util.Collections; import java.util.EnumMap; import java.util.List; import java.util.Map; @@ -71,18 +70,21 @@ public InputStream getContentInputStream() throws IOException { countingInputStream = BoundedInputStream.builder().setInputStream(contentInputStream).get(); contentInputStream = countingInputStream; + // Retrieve checksum algorithm specified in Tus v1 Upload-Checksum header ChecksumAlgorithm checksumAlgorithm = ChecksumAlgorithm.forUploadChecksumHeader(getHeader(HttpHeader.UPLOAD_CHECKSUM)); - List algorithms; + java.util.Set algorithms = new java.util.LinkedHashSet<>(); if (isChunked) { // Since the Checksum header can still come at the end, keep track of all checksums - algorithms = Arrays.asList(ChecksumAlgorithm.values()); - } else if (checksumAlgorithm != null) { - algorithms = Collections.singletonList(checksumAlgorithm); + algorithms.addAll(Arrays.asList(ChecksumAlgorithm.values())); } else { - algorithms = Collections.emptyList(); + if (checksumAlgorithm != null) { + algorithms.add(checksumAlgorithm); + } + algorithms.addAll( + ChecksumAlgorithm.parseDigestHeader(getHeader(HttpHeader.CONTENT_DIGEST)).keySet()); } for (ChecksumAlgorithm algorithm : algorithms) { diff --git a/src/main/java/me/desair/tus/server/util/Utils.java b/src/main/java/me/desair/tus/server/util/Utils.java index 2fa35413..30f02401 100644 --- a/src/main/java/me/desair/tus/server/util/Utils.java +++ b/src/main/java/me/desair/tus/server/util/Utils.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.regex.Pattern; import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; import me.desair.tus.server.checksum.ChecksumAlgorithm; import org.apache.commons.io.serialization.ValidatingObjectInputStream; import org.apache.commons.lang3.StringUtils; @@ -199,4 +200,22 @@ public static ChecksumInfo parseUploadChecksumHeader(HttpServletRequest request) } return null; } + + /** + * Resolves the upload URI from the HTTP request and response context. + * + * @param method The HttpMethod of the request + * @param request The TusServletRequest + * @param response The TusServletResponse + * @return The upload URI string, or null if it cannot be determined + */ + public static String getUploadUri( + HttpMethod method, TusServletRequest request, TusServletResponse response) { + if (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method)) { + return response != null ? response.getHeader(HttpHeader.LOCATION) : null; + } else if (HttpMethod.PATCH.equals(method)) { + return request != null ? request.getRequestURI() : null; + } + return null; + } } diff --git a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java index 7a05efc0..a4f36334 100644 --- a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java +++ b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java @@ -94,6 +94,7 @@ public void testSupportedHttpMethods() { HttpMethod.OPTIONS, HttpMethod.PATCH, HttpMethod.POST, + HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.GET)); @@ -107,7 +108,8 @@ public void testSupportedHttpMethods() { "download", "expiration", "concatenation", - "resumable-uploads-for-http")); + "resumable-uploads-for-http", + "http-digests")); } @Test @@ -115,6 +117,7 @@ public void testDisableFeature() throws Exception { tusFileUploadService.disableTusExtension("download"); tusFileUploadService.disableTusExtension("termination"); tusFileUploadService.disableTusExtension("resumable-uploads-for-http"); + tusFileUploadService.disableTusExtension("http-digests"); assertThat( tusFileUploadService.getSupportedHttpMethods(), diff --git a/src/test/java/me/desair/tus/server/checksum/ChecksumAlgorithmTest.java b/src/test/java/me/desair/tus/server/checksum/ChecksumAlgorithmTest.java index c4dce87a..465ae4d7 100644 --- a/src/test/java/me/desair/tus/server/checksum/ChecksumAlgorithmTest.java +++ b/src/test/java/me/desair/tus/server/checksum/ChecksumAlgorithmTest.java @@ -49,4 +49,126 @@ public void testToString() throws Exception { assertEquals("sha384", ChecksumAlgorithm.SHA384.toString()); assertEquals("sha512", ChecksumAlgorithm.SHA512.toString()); } + + @Test + public void testForHttpDigestName() { + assertEquals(ChecksumAlgorithm.MD5, ChecksumAlgorithm.forHttpDigestName("md5")); + assertEquals(ChecksumAlgorithm.SHA1, ChecksumAlgorithm.forHttpDigestName("sha")); + assertEquals(ChecksumAlgorithm.SHA1, ChecksumAlgorithm.forHttpDigestName("sha1")); + assertEquals(ChecksumAlgorithm.SHA1, ChecksumAlgorithm.forHttpDigestName("sha-1")); + assertEquals(ChecksumAlgorithm.SHA256, ChecksumAlgorithm.forHttpDigestName("sha-256")); + assertEquals(ChecksumAlgorithm.SHA384, ChecksumAlgorithm.forHttpDigestName("sha-384")); + assertEquals(ChecksumAlgorithm.SHA512, ChecksumAlgorithm.forHttpDigestName("sha-512")); + assertEquals(null, ChecksumAlgorithm.forHttpDigestName("unknown")); + assertEquals(null, ChecksumAlgorithm.forHttpDigestName(null)); + } + + @Test + public void testGetSupportedHttpDigestAlgorithmsHeaderValue() { + assertEquals( + "sha-256, sha-512, sha-384, sha, md5", + ChecksumAlgorithm.getSupportedHttpDigestAlgorithmsHeaderValue()); + } + + @Test + public void testParseDigestHeader() { + java.util.Map map = + ChecksumAlgorithm.parseDigestHeader("sha-256=:foo=:, sha-512=:bar=:"); + assertEquals(2, map.size()); + assertEquals("foo=", map.get(ChecksumAlgorithm.SHA256)); + assertEquals("bar=", map.get(ChecksumAlgorithm.SHA512)); + + java.util.Map map2 = + ChecksumAlgorithm.parseDigestHeader("unknown=:abc=:, sha-256=:foo=:"); + assertEquals(1, map2.size()); + assertEquals("foo=", map2.get(ChecksumAlgorithm.SHA256)); + + assertEquals(0, ChecksumAlgorithm.parseDigestHeader(" ").size()); + assertEquals(0, ChecksumAlgorithm.parseDigestHeader(null).size()); + } + + @Test + public void testCleanDigestValue() { + assertEquals(null, ChecksumAlgorithm.cleanDigestValue(null)); + assertEquals("sha256=", ChecksumAlgorithm.cleanDigestValue(":sha256=:")); + assertEquals("abc", ChecksumAlgorithm.cleanDigestValue(" :abc: ")); + assertEquals("sha256=", ChecksumAlgorithm.cleanDigestValue(":sha256=")); + assertEquals("sha256=", ChecksumAlgorithm.cleanDigestValue("sha256=:")); + assertEquals("plain", ChecksumAlgorithm.cleanDigestValue("plain")); + } + + @Test + public void testSelectBestAlgorithmDefaultWeight() { + // No weight specified → defaults to q=1.0 + assertEquals(ChecksumAlgorithm.SHA256, ChecksumAlgorithm.selectBestAlgorithm("sha-256")); + } + + @Test + public void testSelectBestAlgorithmExplicitWeight() { + // Explicit weight: sha-512 has higher q + assertEquals( + ChecksumAlgorithm.SHA512, + ChecksumAlgorithm.selectBestAlgorithm("sha-256;q=0.5, sha-512;q=0.8")); + } + + @Test + public void testSelectBestAlgorithmZeroWeightExcluded() { + // q=0 means "not acceptable" per RFC 9530 + assertEquals(null, ChecksumAlgorithm.selectBestAlgorithm("sha-256;q=0")); + } + + @Test + public void testSelectBestAlgorithmMixedWeights() { + // Mixed weights, sha-384 wins at q=0.9 + assertEquals( + ChecksumAlgorithm.SHA384, + ChecksumAlgorithm.selectBestAlgorithm("sha-256;q=0.3, sha-384;q=0.9, md5;q=0.1")); + } + + @Test + public void testSelectBestAlgorithmDefaultWinsOverExplicit() { + // sha-256 has no q → defaults to 1.0, which beats sha-512 at q=0.5 + assertEquals( + ChecksumAlgorithm.SHA256, ChecksumAlgorithm.selectBestAlgorithm("sha-256, sha-512;q=0.5")); + } + + @Test + public void testSelectBestAlgorithmUnsupportedOnly() { + assertEquals(null, ChecksumAlgorithm.selectBestAlgorithm("unknown-alg")); + } + + @Test + public void testSelectBestAlgorithmNullAndBlank() { + assertEquals(null, ChecksumAlgorithm.selectBestAlgorithm(null)); + assertEquals(null, ChecksumAlgorithm.selectBestAlgorithm(" ")); + } + + @Test + public void testSelectBestAlgorithmInvalidQValue() { + // Invalid q value falls back to q=1.0 + assertEquals( + ChecksumAlgorithm.SHA256, ChecksumAlgorithm.selectBestAlgorithm("sha-256;q=notanumber")); + } + + @Test + public void testSelectBestAlgorithmNonQParameter() { + // Non-q parameter after semicolon → defaults to q=1.0 + assertEquals( + ChecksumAlgorithm.SHA256, ChecksumAlgorithm.selectBestAlgorithm("sha-256;level=1")); + } + + @Test + public void testSelectBestAlgorithmTieBreaking() { + // When weights are equal (or default to 1.0), SHA-256 (priority 5) wins over SHA-512 (priority + // 4) + assertEquals( + ChecksumAlgorithm.SHA256, ChecksumAlgorithm.selectBestAlgorithm("sha-512, sha-256")); + assertEquals( + ChecksumAlgorithm.SHA256, ChecksumAlgorithm.selectBestAlgorithm("sha-256, sha-512")); + + // Explicit equal weights: SHA-256 wins over SHA-512 + assertEquals( + ChecksumAlgorithm.SHA256, + ChecksumAlgorithm.selectBestAlgorithm("sha-512;q=0.8, sha-256;q=0.8")); + } } diff --git a/src/test/java/me/desair/tus/server/core/validation/HttpMethodValidatorTest.java b/src/test/java/me/desair/tus/server/core/validation/HttpMethodValidatorTest.java index d0de4eff..8832218a 100644 --- a/src/test/java/me/desair/tus/server/core/validation/HttpMethodValidatorTest.java +++ b/src/test/java/me/desair/tus/server/core/validation/HttpMethodValidatorTest.java @@ -38,6 +38,21 @@ public void validateInvalid() throws Exception { validator.validate(null, servletRequest, uploadStorageService, null); } + @Test(expected = UnsupportedMethodException.class) + public void validatePut() throws Exception { + validator.validate(HttpMethod.PUT, servletRequest, uploadStorageService, null); + } + + @Test(expected = UnsupportedMethodException.class) + public void validateTrace() throws Exception { + validator.validate(HttpMethod.TRACE, servletRequest, uploadStorageService, null); + } + + @Test(expected = UnsupportedMethodException.class) + public void validateConnect() throws Exception { + validator.validate(HttpMethod.CONNECT, servletRequest, uploadStorageService, null); + } + @Test public void supports() throws Exception { assertThat(validator.supports(HttpMethod.GET), is(true)); diff --git a/src/test/java/me/desair/tus/server/digest/HttpDigestsOptionsRequestHandlerTest.java b/src/test/java/me/desair/tus/server/digest/HttpDigestsOptionsRequestHandlerTest.java new file mode 100644 index 00000000..4a4f0760 --- /dev/null +++ b/src/test/java/me/desair/tus/server/digest/HttpDigestsOptionsRequestHandlerTest.java @@ -0,0 +1,58 @@ +package me.desair.tus.server.digest; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.checksum.ChecksumAlgorithm; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class HttpDigestsOptionsRequestHandlerTest { + + private HttpDigestsOptionsRequestHandler handler; + private UploadStorageService uploadStorageService; + private UploadLockingService uploadLockingService; + + @Before + public void setUp() { + handler = new HttpDigestsOptionsRequestHandler(); + uploadStorageService = mock(UploadStorageService.class); + uploadLockingService = mock(UploadLockingService.class); + } + + @Test + public void testSupports() { + assertThat(handler.supports(HttpMethod.OPTIONS), is(true)); + assertThat(handler.supports(HttpMethod.GET), is(false)); + assertThat(handler.supports(HttpMethod.POST), is(false)); + } + + @Test + public void testProcess() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + TusServletRequest servletRequest = new TusServletRequest(request); + TusServletResponse servletResponse = + new TusServletResponse(new org.springframework.mock.web.MockHttpServletResponse()); + + handler.process( + HttpMethod.OPTIONS, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + "owner", + null); + + String expectedValue = ChecksumAlgorithm.getSupportedHttpDigestAlgorithmsHeaderValue(); + assertThat(servletResponse.getHeader(HttpHeader.WANT_CONTENT_DIGEST), is(expectedValue)); + assertThat(servletResponse.getHeader(HttpHeader.WANT_REPR_DIGEST), is(expectedValue)); + } +} diff --git a/src/test/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandlerTest.java new file mode 100644 index 00000000..fdc1a462 --- /dev/null +++ b/src/test/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandlerTest.java @@ -0,0 +1,165 @@ +package me.desair.tus.server.digest; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.UploadDigestMismatchException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class HttpDigestsPostPutPatchRequestHandlerTest { + + private HttpDigestsPostPutPatchRequestHandler handler; + private UploadStorageService uploadStorageService; + private UploadLockingService uploadLockingService; + + @Before + public void setUp() { + handler = new HttpDigestsPostPutPatchRequestHandler(); + uploadStorageService = mock(UploadStorageService.class); + uploadLockingService = mock(UploadLockingService.class); + } + + @Test + public void testSupports() { + assertThat(handler.supports(HttpMethod.POST), is(true)); + assertThat(handler.supports(HttpMethod.PUT), is(true)); + assertThat(handler.supports(HttpMethod.PATCH), is(true)); + assertThat(handler.supports(HttpMethod.GET), is(false)); + } + + @Test + public void testProcessNoHeaders() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setRequestURI("/files/123"); + TusServletRequest servletRequest = new TusServletRequest(request); + TusServletResponse servletResponse = + new TusServletResponse(new org.springframework.mock.web.MockHttpServletResponse()); + + handler.process( + HttpMethod.POST, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + "owner", + null); + + verify(uploadStorageService, never()).update(any()); + } + + @Test + public void testProcessContentDigestMismatch() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.addHeader(HttpHeader.CONTENT_DIGEST, "sha-256=:invalid-digest-here=:"); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + TusServletRequest servletRequest = new TusServletRequest(request); + // Trigger body reading to compute checksum + byte[] buffer = new byte[100]; + servletRequest.getContentInputStream().read(buffer); + + TusServletResponse servletResponse = + new TusServletResponse(new org.springframework.mock.web.MockHttpServletResponse()); + + assertThrows( + UploadDigestMismatchException.class, + () -> + handler.process( + HttpMethod.POST, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + "owner", + null)); + } + + @Test + public void testProcessContentDigestMatch() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("PATCH"); + request.setRequestURI("/files/123"); + request.addHeader( + HttpHeader.CONTENT_DIGEST, + "sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:, md5=:XUFAKrxLKna5cZ2REBfFkg==:"); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + TusServletRequest servletRequest = new TusServletRequest(request); + // Trigger body reading to compute checksum + byte[] buffer = new byte[100]; + int bytesRead = servletRequest.getContentInputStream().read(buffer); + + TusServletResponse servletResponse = + new TusServletResponse(new org.springframework.mock.web.MockHttpServletResponse()); + + UploadInfo info = new UploadInfo(); + info.setLength(200L); + info.setOffset(100L); + when(uploadStorageService.getUploadInfo("/files/123", "owner")).thenReturn(info); + + handler.process( + HttpMethod.PATCH, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + "owner", + null); + + // No exception thrown, and no upload representation digests updated yet + verify(uploadStorageService, never()).update(any()); + } + + @Test + public void testProcessWithReprDigestRequestedAndUploadComplete() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("PATCH"); + request.setRequestURI("/files/123"); + request.addHeader(HttpHeader.WANT_REPR_DIGEST, "sha-256"); + + TusServletRequest servletRequest = new TusServletRequest(request); + TusServletResponse servletResponse = + new TusServletResponse(new org.springframework.mock.web.MockHttpServletResponse()); + + UploadInfo info = new UploadInfo(); + info.setLength(100L); + info.setOffset(100L); + when(uploadStorageService.getUploadInfo("/files/123", "owner")).thenReturn(info); + when(uploadStorageService.getUploadedBytes("/files/123", "owner")) + .thenReturn(new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))); + + handler.process( + HttpMethod.PATCH, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + "owner", + null); + + verify(uploadStorageService).update(info); + assertThat(info.getRequestedRepresentationDigests(), is("sha-256")); + assertThat( + servletResponse.getHeader(HttpHeader.REPR_DIGEST), + is("sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:")); + } +} diff --git a/src/test/java/me/desair/tus/server/digest/validation/HttpDigestsValidatorTest.java b/src/test/java/me/desair/tus/server/digest/validation/HttpDigestsValidatorTest.java new file mode 100644 index 00000000..97427b78 --- /dev/null +++ b/src/test/java/me/desair/tus/server/digest/validation/HttpDigestsValidatorTest.java @@ -0,0 +1,104 @@ +package me.desair.tus.server.digest.validation; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.ChecksumAlgorithmNotSupportedException; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class HttpDigestsValidatorTest { + + private HttpDigestsValidator validator; + private UploadStorageService uploadStorageService; + + @Before + public void setUp() { + validator = new HttpDigestsValidator(); + uploadStorageService = mock(UploadStorageService.class); + } + + @Test + public void testSupports() { + assertThat(validator.supports(HttpMethod.POST), is(true)); + assertThat(validator.supports(HttpMethod.PUT), is(true)); + assertThat(validator.supports(HttpMethod.PATCH), is(true)); + assertThat(validator.supports(HttpMethod.GET), is(false)); + } + + @Test + public void testValidateNoHeaders() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + validator.validate(HttpMethod.POST, request, uploadStorageService, "owner"); + } + + @Test + public void testValidateValidContentDigest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader( + HttpHeader.CONTENT_DIGEST, "sha-256=:ungWvEM12g1ENZE8BHksJU25yTY7iWi5KyMT+h0B+Ys=:"); + validator.validate(HttpMethod.POST, request, uploadStorageService, "owner"); + } + + @Test + public void testValidateUnsupportedContentDigestAlgorithm() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeader.CONTENT_DIGEST, "unknown=:abc=:"); + + assertThrows( + ChecksumAlgorithmNotSupportedException.class, + () -> validator.validate(HttpMethod.POST, request, uploadStorageService, "owner")); + } + + @Test + public void testValidateInvalidContentDigestHeaderFormat() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeader.CONTENT_DIGEST, "invalid-format-here"); + + assertThrows( + TusException.class, + () -> validator.validate(HttpMethod.POST, request, uploadStorageService, "owner")); + } + + @Test + public void testValidateValidReprDigest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader( + HttpHeader.REPR_DIGEST, "sha-256=:ungWvEM12g1ENZE8BHksJU25yTY7iWi5KyMT+h0B+Ys=:"); + validator.validate(HttpMethod.POST, request, uploadStorageService, "owner"); + } + + @Test + public void testValidateUnsupportedReprDigestAlgorithm() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeader.REPR_DIGEST, "unknown=:abc=:"); + + assertThrows( + ChecksumAlgorithmNotSupportedException.class, + () -> validator.validate(HttpMethod.POST, request, uploadStorageService, "owner")); + } + + @Test + public void testValidateValidWantReprDigest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeader.WANT_REPR_DIGEST, "sha-256, sha-512"); + validator.validate(HttpMethod.POST, request, uploadStorageService, "owner"); + } + + @Test + public void testValidateInvalidWantReprDigestFormat() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeader.WANT_REPR_DIGEST, "invalid-format-here,;"); + + assertThrows( + TusException.class, + () -> validator.validate(HttpMethod.POST, request, uploadStorageService, "owner")); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/HttpDigestsProtocolTest.java b/src/test/java/me/desair/tus/server/rufh/HttpDigestsProtocolTest.java new file mode 100644 index 00000000..c76829f8 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/HttpDigestsProtocolTest.java @@ -0,0 +1,318 @@ +package me.desair.tus.server.rufh; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.TusFileUploadService; +import me.desair.tus.server.upload.UploadInfo; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** End-to-end integration/compliance tests for RFC 9530 HTTP Digests with RUFH protocol. */ +public class HttpDigestsProtocolTest { + + private static final String UPLOAD_URI = "/test/upload"; + private static final String OWNER_KEY = "JOHN_DOE"; + private static Path storagePath; + + private MockHttpServletRequest servletRequest; + private MockHttpServletResponse servletResponse; + private TusFileUploadService tusFileUploadService; + + @BeforeClass + public static void setupDataFolder() throws IOException { + storagePath = Paths.get("target", "tus-digests", "data").toAbsolutePath(); + Files.createDirectories(storagePath); + } + + @AfterClass + public static void destroyDataFolder() throws IOException { + FileUtils.deleteDirectory(storagePath.toFile()); + } + + @Before + public void setUp() { + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + tusFileUploadService = + new TusFileUploadService() + .withUploadUri(UPLOAD_URI) + .withStoragePath(storagePath.toAbsolutePath().toString()) + .withSupportedProtocolVersions(ProtocolVersion.RUFH) + .withUploadDeduplication(true); + } + + /** + * Section 4 of RFC 9530 (Want-Content-Digest / Want-Repr-Digest): "The Want-Content-Digest and + * Want-Repr-Digest HTTP header field are preference fields used by a sender to indicate it wants + * to receive the corresponding digest fields in the response." + */ + @Test + public void testOptionsAdvertisesDigests() throws Exception { + servletRequest.setMethod("OPTIONS"); + servletRequest.setRequestURI(UPLOAD_URI); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_NO_CONTENT)); + assertThat( + servletResponse.getHeader(HttpHeader.WANT_CONTENT_DIGEST), + is("sha-256, sha-512, sha-384, sha, md5")); + assertThat( + servletResponse.getHeader(HttpHeader.WANT_REPR_DIGEST), + is("sha-256, sha-512, sha-384, sha, md5")); + } + + /** + * Section 3 of RFC 9530 (Content-Digest): "The Content-Digest HTTP header field associates one or + * more digests with a message content." + */ + @Test + public void testChunkContentDigestSuccess() throws Exception { + // 1. Create upload session + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(201)); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // 2. Append chunk with valid Content-Digest + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader( + HttpHeader.CONTENT_DIGEST, "sha-256=:yV9g7MInOPrtlLDWsplfHK0LaH22Uz70R1ZXbHIjzjU=:"); + servletRequest.setContent("hello digest".getBytes(StandardCharsets.UTF_8)); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(204)); + assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_OFFSET), is("12")); + } + + /** + * Section 3 of RFC 9530 (Content-Digest): "The Content-Digest HTTP header field associates one or + * more digests with a message content." If the digest does not match, the server MUST consider + * the transfer failed. + */ + @Test + public void testChunkContentDigestFailure() throws Exception { + // 1. Create upload session + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // 2. Append chunk with INVALID Content-Digest + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.CONTENT_DIGEST, "sha-256=:wrongdigestvalue=:"); + servletRequest.setContent("hello digest".getBytes(StandardCharsets.UTF_8)); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + assertThat(servletResponse.getStatus(), is(400)); + assertTrue(servletResponse.getContentAsString().contains("digest-mismatched-values")); + } + + /** + * Section 5 of RFC 9530 (Repr-Digest): "The Repr-Digest HTTP header field associates one or more + * digests with a representation." + */ + @Test + public void testReprDigestCompleteUploadSuccess() throws Exception { + String testContent = "representation test content"; + // base64 SHA-256 of "representation test content" is + // "p+nB8b3M1Y8z7HicF87RkC7C2f9xQnL9M6aW9w/6Ghk=" + // Wait, let's verify actual base64 SHA-256 of "representation test content" + // MessageDigest SHA-256 of "representation test content" -> + // 66e8574a4413155f91456d2b380a06efcf5e8211dbf21fb1bfb41cf43c081e19 + // base64: E0/isChYLiH9/ph8pn/+F6EyUQ+PCZTi8epGL3cuQW0= + String correctBase64 = "E0/isChYLiH9/ph8pn/+F6EyUQ+PCZTi8epGL3cuQW0="; + + // 1. Create upload session with Repr-Digest + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(testContent.length())); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.REPR_DIGEST, "sha-256=:" + correctBase64 + ":"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // 2. Append final chunk + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.setContent(testContent.getBytes(StandardCharsets.UTF_8)); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(200)); + } + + /** + * Section 5 of RFC 9530 (Repr-Digest): "The Repr-Digest HTTP header field associates one or more + * digests with a representation." If mismatch occurs, server rejects upload and stops processing. + */ + @Test + public void testReprDigestCompleteUploadFailure() throws Exception { + String testContent = "representation test content"; + + // 1. Create upload session with INVALID Repr-Digest + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(testContent.length())); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.REPR_DIGEST, "sha-256=:invalidreprdigest=:"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // 2. Append final chunk (triggers Repr-Digest validation on completion) + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.setContent(testContent.getBytes(StandardCharsets.UTF_8)); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + assertThat(servletResponse.getStatus(), is(400)); + assertTrue(servletResponse.getContentAsString().contains("digest-mismatched-values")); + + // Verify written bytes were cleaned up (truncated back to 0) + UploadInfo info = tusFileUploadService.getUploadInfo(uploadLocation, OWNER_KEY); + assertThat(info.getOffset(), is(0L)); + } + + /** + * Section 4 of RFC 9530: Want-Repr-Digest indicates capabilities and preferences. Server returns + * Repr-Digest continuously or in final response. + */ + @Test + public void testWantReprDigestContinuousUpdates() throws Exception { + String chunk1 = "chunk one "; + String chunk2 = "chunk two"; + // base64 SHA-256 of "chunk one " is "W/Wf9eL0Xg3V5bV5k9K6YVwU8O7g=" + // Wait, let's verify actual base64 SHA-256 of "chunk one ": + // MessageDigest SHA-256 of "chunk one " -> Z8m4uP/R5D6q6Y0Vv5v1g6tH=... + // Let's just retrieve whatever the server outputs. + + // 1. Create upload session with Want-Repr-Digest + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "20"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.WANT_REPR_DIGEST, "sha-256"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + assertThat(servletResponse.getHeader(HttpHeader.REPR_DIGEST), notNullValue()); + + // 2. Append chunk 1 + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.setContent(chunk1.getBytes(StandardCharsets.UTF_8)); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getHeader(HttpHeader.REPR_DIGEST), notNullValue()); + } + + /** End-to-end test verifying withUploadDeduplication functionality using HTTP Digests. */ + @Test + public void testUploadDeduplicationWithHttpDigests() throws Exception { + String testContent = "deduplication test content"; + String correctBase64 = "VFeMPTNSgeFcu+9IbyApEaxAC8A8Amvxn7SoLGL4sGM="; + + // 1. Complete Parent upload + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(testContent.length())); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.REPR_DIGEST, "sha-256=:" + correctBase64 + ":"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String parentLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(parentLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.setContent(testContent.getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(200)); + + // 2. Complete Child upload + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(testContent.length())); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.REPR_DIGEST, "sha-256=:" + correctBase64 + ":"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String childLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(childLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.setContent(testContent.getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(200)); + + // 3. Verify parent and child upload info links correctly via duplicatesUploadId + UploadInfo parentInfo = tusFileUploadService.getUploadInfo(parentLocation, OWNER_KEY); + UploadInfo childInfo = tusFileUploadService.getUploadInfo(childLocation, OWNER_KEY); + + assertThat(childInfo.getDuplicatesUploadId(), is(parentInfo.getId())); + + // Verify child data file was deleted + String childIdStr = StringUtils.substringAfterLast(childLocation, "/"); + Path childDataPath = storagePath.resolve("uploads").resolve(childIdStr).resolve("data"); + assertFalse(Files.exists(childDataPath)); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java b/src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java index d5953e4e..8e2e22b6 100644 --- a/src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java +++ b/src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java @@ -3,6 +3,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import me.desair.tus.server.ProtocolVersion; import org.junit.Test; /** Tests for {@link ResumableUploadsForHttpProtocol} and its interim response strategies. */ @@ -22,5 +23,13 @@ public void testProtocolExtension() { // Call sendInterimResponse to cover line 14 of NoOpInterimResponseStrategy strategy.sendInterimResponse(null, null, 0L); + // Test mustReprocessOnError coverage + assertThat( + protocol.mustReprocessOnError(me.desair.tus.server.HttpMethod.POST, ProtocolVersion.RUFH), + is(true)); + assertThat( + protocol.mustReprocessOnError( + me.desair.tus.server.HttpMethod.POST, ProtocolVersion.TUS_1_0_0), + is(false)); } } 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 51190565..a27db941 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 @@ -28,6 +28,7 @@ import java.util.Arrays; import java.util.List; import java.util.UUID; +import me.desair.tus.server.checksum.ChecksumAlgorithm; import me.desair.tus.server.exception.InvalidUploadOffsetException; import me.desair.tus.server.exception.UploadNotFoundException; import me.desair.tus.server.upload.UploadId; @@ -738,11 +739,11 @@ public void cleanupExpiredUploadsWhenStorageDirectoryNotExists() throws Exceptio } @Test - public void testGetUploadInfoByChecksumWithUnsafeChecksums() { - List unsafeChecksums = - Arrays.asList(" ", "../test", "test/../test", "test/test", "test\\test", "..", "/"); - - for (String checksum : unsafeChecksums) { + public void testGetUploadInfoByChecksumWithUnsafeChecksums() throws Exception { + // These values cannot be parsed to non-empty base64 bytes, so they remain unsafe and throw + // IOException + List unsafeEmptyChecksums = Arrays.asList(" ", "..", "/"); + for (String checksum : unsafeEmptyChecksums) { assertThrows( IOException.class, () -> { @@ -750,6 +751,18 @@ public void testGetUploadInfoByChecksumWithUnsafeChecksums() { checksum, me.desair.tus.server.checksum.ChecksumAlgorithm.SHA256); }); } + + // These values contain path traversal characters but are parsed as base64 and converted to safe + // hex strings. + // They should not throw IOException, but return null since the resolved file does not exist. + List unsafeBase64Checksums = + Arrays.asList("../test", "test/../test", "test/test", "test\\test"); + for (String checksum : unsafeBase64Checksums) { + assertThat( + storageService.getUploadInfoByChecksum( + checksum, me.desair.tus.server.checksum.ChecksumAlgorithm.SHA256), + is(nullValue())); + } } @Test @@ -762,7 +775,8 @@ public void testUpdateWithUnsafeChecksums() throws Exception { info = storageService.create(info, null); info.setOffset(100L); - info.setChecksum("test/path"); + // Using an empty/unsafe value that fails base64 parsing and remains unsafe + info.setChecksum(" / "); info.setChecksumAlgorithm(me.desair.tus.server.checksum.ChecksumAlgorithm.SHA256); final UploadInfo finalInfo = info; @@ -777,7 +791,7 @@ public void testUpdateWithUnsafeChecksums() throws Exception { public void testTerminateUploadWithUnsafeChecksums() throws Exception { UploadInfo info = new UploadInfo(); info.setId(new UploadId("test-id")); - info.setChecksum("test/path"); + info.setChecksum(" / "); info.setChecksumAlgorithm(me.desair.tus.server.checksum.ChecksumAlgorithm.SHA256); assertThrows( @@ -798,4 +812,28 @@ public void testMaxAppendSizeDefaulting() { diskService.setMaxAppendSize(10000L); assertThat(diskService.getMaxAppendSize(), is(10000L)); } + + @Test + public void testGetUploadInfoByChecksumWithNullValues() throws Exception { + // 1. null checksum should return null + assertThat( + storageService.getUploadInfoByChecksum(null, ChecksumAlgorithm.SHA256), is(nullValue())); + + // 2. null algorithm should return null + assertThat( + storageService.getUploadInfoByChecksum( + "ba7816bc4335da0d4435913c04792c254db9c9363b8968b92b2313fa1d01f98b", null), + is(nullValue())); + } + + @Test + public void testGetUploadInfoByChecksumWithValidBase64ContainingSlash() throws Exception { + // Valid SHA-256 base64 representation digest containing a slash: + // "E0/isChYLiH9/ph8pn/+F6EyUQ+PCZTi8epGL3cuQW0=" (length 44, decodes to 32 bytes) + // It should be decoded to hex (which is safe) and successfully resolved (returning null). + assertThat( + storageService.getUploadInfoByChecksum( + "E0/isChYLiH9/ph8pn/+F6EyUQ+PCZTi8epGL3cuQW0=", ChecksumAlgorithm.SHA256), + is(nullValue())); + } } diff --git a/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java b/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java index ed05db58..f0994bbb 100644 --- a/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java +++ b/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java @@ -85,4 +85,18 @@ public void testSanitizeHeaderValue() { assertThat(StructuredHeaderUtil.sanitizeHeaderValue("test\0null"), is("testnull")); assertThat(StructuredHeaderUtil.sanitizeHeaderValue(null), nullValue()); } + + @Test + public void testParseList() { + java.util.List list = StructuredHeaderUtil.parseList("sha-256, sha-512"); + assertThat(list.size(), is(2)); + assertThat(list.get(0), is("sha-256")); + assertThat(list.get(1), is("sha-512")); + + java.util.List list2 = StructuredHeaderUtil.parseList("sha-256,,sha-512"); + assertThat(list2.size(), is(2)); + + assertThat(StructuredHeaderUtil.parseList(null).isEmpty(), is(true)); + assertThat(StructuredHeaderUtil.parseList(" ").isEmpty(), is(true)); + } } diff --git a/src/test/java/me/desair/tus/server/util/UtilsTest.java b/src/test/java/me/desair/tus/server/util/UtilsTest.java index e91ccab3..8b1b4c67 100644 --- a/src/test/java/me/desair/tus/server/util/UtilsTest.java +++ b/src/test/java/me/desair/tus/server/util/UtilsTest.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.UUID; import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; import me.desair.tus.server.checksum.ChecksumAlgorithm; import org.apache.commons.io.FileUtils; import org.junit.AfterClass; @@ -297,6 +298,23 @@ public void testParseUploadChecksumHeaderInvalidCharacters() { } } + @Test + public void testGetUploadUri() { + TusServletRequest request = mock(TusServletRequest.class); + TusServletResponse response = mock(TusServletResponse.class); + + when(request.getRequestURI()).thenReturn("/files/123"); + when(response.getHeader(HttpHeader.LOCATION)).thenReturn("/files/location"); + + assertThat(Utils.getUploadUri(HttpMethod.POST, request, response), is("/files/location")); + assertThat(Utils.getUploadUri(HttpMethod.PUT, request, response), is("/files/location")); + assertThat(Utils.getUploadUri(HttpMethod.PATCH, request, response), is("/files/123")); + assertThat(Utils.getUploadUri(HttpMethod.GET, request, response), is(nullValue())); + + assertThat(Utils.getUploadUri(HttpMethod.POST, request, null), is(nullValue())); + assertThat(Utils.getUploadUri(HttpMethod.PATCH, null, response), is(nullValue())); + } + /** Simple serializable class for testing. */ public static class TestSerializable implements Serializable { private static final long serialVersionUID = 1L; From 287f920bd1403c81679e0a72f6a705ce57ba0511 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Tue, 7 Jul 2026 22:58:55 +0200 Subject: [PATCH 9/9] Support download extension in RUFH protocol version --- README.md | 2 +- docs/MIGRATION.md | 5 + .../me/desair/tus/server/RequestHandler.java | 11 ++ .../desair/tus/server/RequestValidator.java | 11 ++ .../server/download/DownloadExtension.java | 7 + .../download/DownloadGetRequestHandler.java | 11 +- .../DownloadUploadMetadataHandler.java | 41 +++++ .../util/AbstractExtensionRequestHandler.java | 9 + .../tus/server/util/AbstractTusExtension.java | 6 +- .../DownloadGetRequestHandlerTest.java | 11 +- .../DownloadUploadMetadataHandlerTest.java | 112 +++++++++++++ .../server/rufh/DownloadProtocolRufhTest.java | 158 ++++++++++++++++++ 12 files changed, 373 insertions(+), 11 deletions(-) create mode 100644 src/main/java/me/desair/tus/server/download/DownloadUploadMetadataHandler.java create mode 100644 src/test/java/me/desair/tus/server/download/DownloadUploadMetadataHandlerTest.java create mode 100644 src/test/java/me/desair/tus/server/rufh/DownloadProtocolRufhTest.java diff --git a/README.md b/README.md index c191e9dd..b9443a75 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core- * [expiration](https://tus.io/protocols/resumable-upload.html#expiration): You can instruct the tus-java-server library to cleanup uploads that are older than a configurable period. * [concatenation](https://tus.io/protocols/resumable-upload.html#concatenation): This extension can be used to concatenate multiple uploads into a single final upload enabling clients to perform parallel uploads and to upload non-contiguous chunks. * [concatenation-unfinished](https://tus.io/protocols/resumable-upload.html#concatenation): The client is allowed send the request to concatenate partial uploads while these partial uploads are still in progress. -* `download`: The (unofficial) download extension allows clients to download uploaded files using a HTTP `GET` request. You can enable this extension by calling the `withDownloadFeature()` method. +* `download`: The (unofficial) download extension allows clients to download uploaded files using a HTTP `GET` request. You can enable this extension by calling the `withDownloadFeature()` method. This extension applies to both the Tus protocol and the RUFH protocol. * `http-digests`: An extension implementing RFC 9530 to verify data integrity for the Resumable Uploads for HTTP (RUFH) protocol. Supported headers include `Content-Digest`, `Repr-Digest`, `Want-Content-Digest`, and `Want-Repr-Digest`. ## Usage and Configuration diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 5f214edd..032c8a58 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -128,6 +128,11 @@ In RUFH protocol, integrity verification is achieved using **RFC 9530 HTTP Diges - **Chunk verification**: Use the `Content-Digest` header containing the cryptographic hash of the transmitted chunk (e.g. `Content-Digest: sha-256=:...:`). - **Full file verification**: Use the `Repr-Digest` header in the creation request or final append request to define the expected digest of the complete file. Alternatively, send `Want-Repr-Digest` in the request to receive the calculated file digest from the server in the response's `Repr-Digest` header. +### 4.5 Download Extension +The unofficial `download` extension is fully supported under both protocols. Once enabled via the `withDownloadFeature()` method: +- Clients can download completed uploads using a standard HTTP `GET` request to the upload's Location URI, regardless of whether it was uploaded via Tus 1.0.0 or RUFH. +- For RUFH download responses, all Tus-specific headers (such as `Upload-Metadata` or `Tus-Extension`) are omitted. + --- ## 5. Reverse Proxies & Load Balancers diff --git a/src/main/java/me/desair/tus/server/RequestHandler.java b/src/main/java/me/desair/tus/server/RequestHandler.java index a22254bc..33fa1c41 100644 --- a/src/main/java/me/desair/tus/server/RequestHandler.java +++ b/src/main/java/me/desair/tus/server/RequestHandler.java @@ -72,4 +72,15 @@ default HttpProblemDetails process( * @return true if this handler processes errors, false otherwise */ boolean isErrorHandler(); + + /** + * Test if this request handler supports the given HTTP method and protocol version + * + * @param method The current HTTP method + * @param version The protocol version of the request + * @return true if supported, false otherwise + */ + default boolean supports(HttpMethod method, ProtocolVersion version) { + return supports(method); + } } diff --git a/src/main/java/me/desair/tus/server/RequestValidator.java b/src/main/java/me/desair/tus/server/RequestValidator.java index 7aa150e4..430b96e3 100644 --- a/src/main/java/me/desair/tus/server/RequestValidator.java +++ b/src/main/java/me/desair/tus/server/RequestValidator.java @@ -32,4 +32,15 @@ void validate( * @return true if supported, false otherwise */ boolean supports(HttpMethod method); + + /** + * Test if this validator supports the given HTTP method and protocol version + * + * @param method The current HTTP method + * @param version The protocol version of the request + * @return true if supported, false otherwise + */ + default boolean supports(HttpMethod method, ProtocolVersion version) { + return supports(method); + } } diff --git a/src/main/java/me/desair/tus/server/download/DownloadExtension.java b/src/main/java/me/desair/tus/server/download/DownloadExtension.java index c6d6cebf..f369ecf7 100644 --- a/src/main/java/me/desair/tus/server/download/DownloadExtension.java +++ b/src/main/java/me/desair/tus/server/download/DownloadExtension.java @@ -4,6 +4,7 @@ import java.util.Collection; import java.util.List; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.RequestHandler; import me.desair.tus.server.RequestValidator; import me.desair.tus.server.util.AbstractTusExtension; @@ -24,6 +25,11 @@ public Collection getMinimalSupportedHttpMethods() { return Arrays.asList(HttpMethod.OPTIONS, HttpMethod.GET); } + @Override + public boolean isApplicable(HttpMethod method, ProtocolVersion version) { + return true; + } + @Override protected void initValidators(List requestValidators) { // All validation is all read done by the Core protocol @@ -31,6 +37,7 @@ protected void initValidators(List requestValidators) { @Override protected void initRequestHandlers(List requestHandlers) { + requestHandlers.add(new DownloadUploadMetadataHandler()); requestHandlers.add(new DownloadGetRequestHandler()); requestHandlers.add(new DownloadOptionsRequestHandler()); } diff --git a/src/main/java/me/desair/tus/server/download/DownloadGetRequestHandler.java b/src/main/java/me/desair/tus/server/download/DownloadGetRequestHandler.java index 6e2e3cfc..1cb382ef 100644 --- a/src/main/java/me/desair/tus/server/download/DownloadGetRequestHandler.java +++ b/src/main/java/me/desair/tus/server/download/DownloadGetRequestHandler.java @@ -7,6 +7,7 @@ import java.util.Objects; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.exception.UploadInProgressException; import me.desair.tus.server.upload.UploadInfo; @@ -26,6 +27,12 @@ public boolean supports(HttpMethod method) { return HttpMethod.GET.equals(method); } + @Override + public boolean supports(HttpMethod method, ProtocolVersion version) { + return HttpMethod.GET.equals(method) + && (version == ProtocolVersion.TUS_1_0_0 || version == ProtocolVersion.RUFH); + } + @Override public void process( HttpMethod method, @@ -57,10 +64,6 @@ public void process( servletResponse.setHeader( HttpHeader.CONTENT_TYPE, info.getFileMimeType().replaceAll("[\r\n]", "")); - if (info.hasMetadata()) { - servletResponse.setHeader(HttpHeader.UPLOAD_METADATA, info.getEncodedMetadata()); - } - uploadStorageService.copyUploadTo(info, servletResponse.getOutputStream()); } diff --git a/src/main/java/me/desair/tus/server/download/DownloadUploadMetadataHandler.java b/src/main/java/me/desair/tus/server/download/DownloadUploadMetadataHandler.java new file mode 100644 index 00000000..2326f50d --- /dev/null +++ b/src/main/java/me/desair/tus/server/download/DownloadUploadMetadataHandler.java @@ -0,0 +1,41 @@ +package me.desair.tus.server.download; + +import java.io.IOException; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; + +/** Request handler to add the Tus-specific Upload-Metadata header for download requests. */ +public class DownloadUploadMetadataHandler extends AbstractRequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.GET.equals(method); + } + + @Override + public boolean supports(HttpMethod method, ProtocolVersion version) { + return HttpMethod.GET.equals(method) && version == ProtocolVersion.TUS_1_0_0; + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException { + + UploadInfo info = uploadStorageService.getUploadInfo(servletRequest.getRequestURI(), ownerKey); + if (info != null && !info.isUploadInProgress() && info.hasMetadata()) { + servletResponse.setHeader(HttpHeader.UPLOAD_METADATA, info.getEncodedMetadata()); + } + } +} diff --git a/src/main/java/me/desair/tus/server/util/AbstractExtensionRequestHandler.java b/src/main/java/me/desair/tus/server/util/AbstractExtensionRequestHandler.java index f68a588f..04f06470 100644 --- a/src/main/java/me/desair/tus/server/util/AbstractExtensionRequestHandler.java +++ b/src/main/java/me/desair/tus/server/util/AbstractExtensionRequestHandler.java @@ -2,6 +2,7 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.upload.UploadStorageService; import org.apache.commons.lang3.StringUtils; @@ -13,6 +14,14 @@ public boolean supports(HttpMethod method) { return HttpMethod.OPTIONS.equals(method); } + @Override + public boolean supports(HttpMethod method, ProtocolVersion version) { + if (version == ProtocolVersion.RUFH) { + return false; + } + return supports(method); + } + @Override public void process( HttpMethod method, diff --git a/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java b/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java index 77fdc695..c6dc66bc 100644 --- a/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java +++ b/src/main/java/me/desair/tus/server/util/AbstractTusExtension.java @@ -55,7 +55,7 @@ public void validate( } for (RequestValidator requestValidator : requestValidators) { - if (requestValidator.supports(method)) { + if (requestValidator.supports(method, version)) { requestValidator.validate(method, servletRequest, uploadStorageService, ownerKey); } } @@ -95,7 +95,7 @@ public void process( } for (RequestHandler requestHandler : requestHandlers) { - if (requestHandler.supports(method)) { + if (requestHandler.supports(method, version)) { requestHandler.process( method, servletRequest, @@ -149,7 +149,7 @@ public HttpProblemDetails handleError( HttpProblemDetails problemDetails = null; for (RequestHandler requestHandler : requestHandlers) { - if (requestHandler.supports(method) && requestHandler.isErrorHandler()) { + if (requestHandler.supports(method, version) && requestHandler.isErrorHandler()) { HttpProblemDetails pd = requestHandler.process( method, diff --git a/src/test/java/me/desair/tus/server/download/DownloadGetRequestHandlerTest.java b/src/test/java/me/desair/tus/server/download/DownloadGetRequestHandlerTest.java index 35ff3ead..a0655e51 100644 --- a/src/test/java/me/desair/tus/server/download/DownloadGetRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/download/DownloadGetRequestHandlerTest.java @@ -14,6 +14,7 @@ import java.util.UUID; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.exception.UploadInProgressException; import me.desair.tus.server.upload.UploadId; import me.desair.tus.server.upload.UploadInfo; @@ -56,6 +57,13 @@ public void supports() throws Exception { assertThat(handler.supports(HttpMethod.OPTIONS), is(false)); assertThat(handler.supports(HttpMethod.PATCH), is(false)); assertThat(handler.supports(null), is(false)); + + assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.TUS_1_0_0), is(true)); + assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.RUFH), is(true)); + assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.AUTO), is(false)); + assertThat(handler.supports(HttpMethod.GET, null), is(false)); + assertThat(handler.supports(HttpMethod.POST, ProtocolVersion.TUS_1_0_0), is(false)); + assertThat(handler.supports(null, ProtocolVersion.TUS_1_0_0), is(false)); } @Test @@ -85,9 +93,6 @@ public void testWithCompletedUploadWithMetadata() throws Exception { servletResponse.getHeader(HttpHeader.CONTENT_DISPOSITION), is("attachment; filename=\"test.jpg\"; filename*=UTF-8''test.jpg")); assertThat(servletResponse.getHeader(HttpHeader.CONTENT_TYPE), is("image/jpeg")); - assertThat( - servletResponse.getHeader(HttpHeader.UPLOAD_METADATA), - is("name dGVzdC5qcGc=,type aW1hZ2UvanBlZw==")); info.setEncodedMetadata("name TmHDr3ZlIGZpbGUudHh0,type dGV4dC9wbGFpbg=="); handler.process( diff --git a/src/test/java/me/desair/tus/server/download/DownloadUploadMetadataHandlerTest.java b/src/test/java/me/desair/tus/server/download/DownloadUploadMetadataHandlerTest.java new file mode 100644 index 00000000..be625667 --- /dev/null +++ b/src/test/java/me/desair/tus/server/download/DownloadUploadMetadataHandlerTest.java @@ -0,0 +1,112 @@ +package me.desair.tus.server.download; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class DownloadUploadMetadataHandlerTest { + + private DownloadUploadMetadataHandler handler; + + private MockHttpServletRequest servletRequest; + + private MockHttpServletResponse servletResponse; + + @Mock private UploadStorageService uploadStorageService; + + @Before + public void setUp() { + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + handler = new DownloadUploadMetadataHandler(); + } + + @Test + public void supports() { + assertThat(handler.supports(HttpMethod.GET), is(true)); + assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.TUS_1_0_0), is(true)); + assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.RUFH), is(false)); + assertThat(handler.supports(HttpMethod.POST, ProtocolVersion.TUS_1_0_0), is(false)); + } + + @Test + public void testProcessCompletedUploadWithMetadata() throws Exception { + UploadInfo info = new UploadInfo(); + info.setId(new UploadId(UUID.randomUUID())); + info.setOffset(10L); + info.setLength(10L); + info.setEncodedMetadata("name dGVzdC5qcGc=,type aW1hZ2UvanBlZw=="); + when(uploadStorageService.getUploadInfo(nullable(String.class), nullable(String.class))) + .thenReturn(info); + + handler.process( + HttpMethod.GET, + new TusServletRequest(servletRequest), + new TusServletResponse(servletResponse), + uploadStorageService, + null); + + assertThat( + servletResponse.getHeader(HttpHeader.UPLOAD_METADATA), + is("name dGVzdC5qcGc=,type aW1hZ2UvanBlZw==")); + } + + @Test + public void testProcessCompletedUploadWithoutMetadata() throws Exception { + UploadInfo info = new UploadInfo(); + info.setId(new UploadId(UUID.randomUUID())); + info.setOffset(10L); + info.setLength(10L); + when(uploadStorageService.getUploadInfo(nullable(String.class), nullable(String.class))) + .thenReturn(info); + + handler.process( + HttpMethod.GET, + new TusServletRequest(servletRequest), + new TusServletResponse(servletResponse), + uploadStorageService, + null); + + assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_METADATA), nullValue()); + } + + @Test + public void testProcessUploadInProgress() throws Exception { + UploadInfo info = new UploadInfo(); + info.setId(new UploadId(UUID.randomUUID())); + info.setOffset(5L); + info.setLength(10L); + info.setEncodedMetadata("name dGVzdC5qcGc="); + when(uploadStorageService.getUploadInfo(nullable(String.class), nullable(String.class))) + .thenReturn(info); + + handler.process( + HttpMethod.GET, + new TusServletRequest(servletRequest), + new TusServletResponse(servletResponse), + uploadStorageService, + null); + + assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_METADATA), nullValue()); + } +} diff --git a/src/test/java/me/desair/tus/server/rufh/DownloadProtocolRufhTest.java b/src/test/java/me/desair/tus/server/rufh/DownloadProtocolRufhTest.java new file mode 100644 index 00000000..a9edca09 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/DownloadProtocolRufhTest.java @@ -0,0 +1,158 @@ +package me.desair.tus.server.rufh; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.ProtocolVersion; +import me.desair.tus.server.TusFileUploadService; +import org.apache.commons.io.FileUtils; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** End-to-end integration tests for the download extension with RUFH protocol. */ +public class DownloadProtocolRufhTest { + + private static final String UPLOAD_URI = "/test/upload"; + private static final String OWNER_KEY = "JOHN_DOE"; + private static Path storagePath; + + private MockHttpServletRequest servletRequest; + private MockHttpServletResponse servletResponse; + private TusFileUploadService tusFileUploadService; + + @BeforeClass + public static void setupDataFolder() throws IOException { + storagePath = Paths.get("target", "tus-rufh-download", "data").toAbsolutePath(); + Files.createDirectories(storagePath); + } + + @AfterClass + public static void destroyDataFolder() throws IOException { + FileUtils.deleteDirectory(storagePath.toFile()); + } + + @Before + public void setUp() { + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + tusFileUploadService = + new TusFileUploadService() + .withUploadUri(UPLOAD_URI) + .withStoragePath(storagePath.toAbsolutePath().toString()) + .withSupportedProtocolVersions(ProtocolVersion.RUFH) + .withDownloadFeature(); + } + + @Test + public void testDownloadCompletedRufhUpload() throws Exception { + // 1. Create upload session + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "14"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename dGVzdC5qcGc="); // test.jpg + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(201)); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // 2. Append chunk and complete upload + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.setContent("hello download".getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(200)); + + // 3. Download the upload via GET + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("GET"); + servletRequest.setRequestURI(uploadLocation); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_OK)); + assertThat(servletResponse.getContentAsString(), is("hello download")); + assertThat(servletResponse.getHeader(HttpHeader.CONTENT_LENGTH), is("14")); + String uploadId = uploadLocation.substring(uploadLocation.lastIndexOf('/') + 1); + assertThat( + servletResponse.getHeader(HttpHeader.CONTENT_DISPOSITION), + is(String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", uploadId, uploadId))); + + // 4. Assert that all Tus-specific response headers are completely absent + assertThat(servletResponse.getHeader(HttpHeader.TUS_RESUMABLE), nullValue()); + assertThat(servletResponse.getHeader(HttpHeader.TUS_VERSION), nullValue()); + assertThat(servletResponse.getHeader(HttpHeader.TUS_EXTENSION), nullValue()); + assertThat(servletResponse.getHeader(HttpHeader.TUS_MAX_SIZE), nullValue()); + assertThat(servletResponse.getHeader(HttpHeader.TUS_CHECKSUM_ALGORITHM), nullValue()); + assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_METADATA), nullValue()); + } + + @Test + public void testDownloadInProgressRufhUpload() throws Exception { + // 1. Create upload session + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "14"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(201)); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // 2. Download in-progress upload via GET + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("GET"); + servletRequest.setRequestURI(uploadLocation); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + // Should return 422 Unprocessable Entity + assertThat(servletResponse.getStatus(), is(422)); + } + + @Test + public void testDownloadFeatureDisabled() throws Exception { + // 1. Setup service without download feature + TusFileUploadService serviceWithoutDownload = + new TusFileUploadService() + .withUploadUri(UPLOAD_URI) + .withStoragePath(storagePath.toAbsolutePath().toString()) + .withSupportedProtocolVersions(ProtocolVersion.RUFH); + + // 2. Create upload session + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "14"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.setContent("hello download".getBytes(StandardCharsets.UTF_8)); + serviceWithoutDownload.process(servletRequest, servletResponse, OWNER_KEY); + assertThat(servletResponse.getStatus(), is(200)); + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // 3. Download via GET + servletRequest = new MockHttpServletRequest(); + servletResponse = new MockHttpServletResponse(); + servletRequest.setMethod("GET"); + servletRequest.setRequestURI(uploadLocation); + serviceWithoutDownload.process(servletRequest, servletResponse, OWNER_KEY); + + // Without the download feature, GET is not processed and defaults to 200 OK + assertThat(servletResponse.getStatus(), is(200)); + assertThat(servletResponse.getContentAsString(), is("")); + } +}