diff --git a/.github/workflows/spec-watch.yml b/.github/workflows/spec-watch.yml new file mode 100644 index 00000000..d330d4ef --- /dev/null +++ b/.github/workflows/spec-watch.yml @@ -0,0 +1,77 @@ +name: Monitor IETF Resumable Uploads Spec Updates + +on: + schedule: + - cron: "0 0 * * 6" # Run weekly on Saturday 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 fce48aff..862128f6 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.3] ### Added diff --git a/README.md b/README.md index d7109a7c..aa46fb83 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,39 @@ 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) | +| **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,creation-with-upload,checksum,checksum-trailer,termination,expiration,concatenation,concatenation-unfinished`. Optionally you can also enable an unofficial `download` extension (see [configuration section](#usage-and-configuration)). @@ -36,6 +69,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. +* [http-digests](https://datatracker.ietf.org/doc/rfc9530/): 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`. * `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. * `cors`: The (unofficial) CORS extension adds native CORS support out-of-the-box, setting CORS headers for all requests and responses, and handling preflight `OPTIONS` requests automatically. It is enabled by default. @@ -45,6 +79,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). @@ -57,6 +92,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", "creation-with-upload", "checksum", "expiration", "concatenation", "termination", "download" and "cors". 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. @@ -76,10 +115,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..032c8a58 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,143 @@ +# 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) | +| **Checksum / Data Integrity** | `Upload-Checksum: sha1 ...` | `Content-Digest` and `Repr-Digest` (RFC 9530) | + +--- + +## 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 + } + ``` + +### 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. + +### 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 + +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 e19600ff..2b82b5f8 100644 --- a/pom.xml +++ b/pom.xml @@ -1,15 +1,18 @@ - + 4.0.0 me.desair.tus tus-java-server - 1.0.0-3.4-SNAPSHOT + 2.0.0-SNAPSHOT jar ${project.groupId}:${project.artifactId} Server-side implementation of the open file upload protocol tus (https://tus.io/) - that supports resumable file uploads for small and very large files + and IETF Resumable Uploads for HTTP (rufh) that support resumable file uploads + for small and very large files https://github.com/tomdesair/tus-java-server 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..4c91145d 100644 --- a/src/main/java/me/desair/tus/server/HttpHeader.java +++ b/src/main/java/me/desair/tus/server/HttpHeader.java @@ -101,6 +101,51 @@ 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"; + + /** 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 new file mode 100644 index 00000000..658aca6b --- /dev/null +++ b/src/main/java/me/desair/tus/server/HttpProblemDetails.java @@ -0,0 +1,295 @@ +package me.desair.tus.server; + +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.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); + } + + /** + * 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. + * + * @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.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/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..33fa1c41 100644 --- a/src/main/java/me/desair/tus/server/RequestHandler.java +++ b/src/main/java/me/desair/tus/server/RequestHandler.java @@ -2,21 +2,85 @@ 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); - void process( + /** + * 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 + */ + 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. + * + * @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 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 HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, ownerKey); + return null; + } + + /** + * Test if this handler is an error handler invoked during exception processing. + * + * @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/TusExtension.java b/src/main/java/me/desair/tus/server/TusExtension.java index 15d8102f..d69dac8a 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,48 @@ void handleError( String ownerKey) throws IOException, TusException; + /** + * 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 + * @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 + * @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 HttpProblemDetails handleError( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version, + TusException exception) + throws IOException, TusException { + handleError(method, servletRequest, servletResponse, uploadStorageService, ownerKey); + 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 3a78004e..e3898e35 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -16,9 +16,11 @@ import me.desair.tus.server.cors.CorsExtension; import me.desair.tus.server.creation.CreationExtension; import me.desair.tus.server.creationwithupload.CreationWithUploadExtension; +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; +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; @@ -52,6 +54,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() { @@ -72,6 +75,31 @@ protected void initFeatures() { addTusExtension(new ExpirationExtension()); addTusExtension(new ConcatenationExtension()); addTusExtension(new CorsExtension()); + addTusExtension(new ResumableUploadsForHttpProtocol()); + addTusExtension(new HttpDigestsExtension()); + } + + /** + * 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; } /** @@ -101,6 +129,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 @@ -131,6 +175,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); @@ -140,6 +185,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 @@ -331,7 +385,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())) { @@ -471,37 +524,75 @@ 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; + } + + // 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; + } + 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); } } @@ -510,7 +601,8 @@ protected void processTusException( TusServletRequest request, TusServletResponse response, String ownerKey, - TusException exception) + TusException exception, + ProtocolVersion version) throws IOException { int status = exception.getStatus(); @@ -523,26 +615,49 @@ protected void processTusException( status, message); + response.setStatus(status); + + HttpProblemDetails problemDetails = null; try { for (TusExtension feature : enabledFeatures.values()) { - - if (!request.isProcessedBy(feature)) { + if (!request.isProcessedBy(feature) || feature.mustReprocessOnError(method, version)) { request.addProcessor(feature); - feature.handleError(method, request, response, uploadStorageService, ownerKey); + HttpProblemDetails pd = + feature.handleError( + method, + request, + response, + uploadStorageService, + uploadLockingService, + ownerKey, + version, + exception); + if (pd != null) { + problemDetails = pd; + } } } // Since an error occurred, the bytes we have written are probably not valid. So remove // them. UploadInfo uploadInfo = - uploadStorageService.getUploadInfo(Utils.getUploadURI(request, response), ownerKey); + uploadStorageService.getUploadInfo(Utils.getUploadUri(request, response), ownerKey); uploadStorageService.removeLastNumberOfBytes(uploadInfo, request.getBytesRead()); } catch (TusException ex) { 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()) { + if (problemDetails != null) { + problemDetails.writeTo(response); + } else { + response.sendError(status, message); + } + } } private void updateSupportedHttpMethods() { 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/ChecksumRequestHandler.java b/src/main/java/me/desair/tus/server/checksum/ChecksumRequestHandler.java index 2ceb4c54..810e896d 100644 --- a/src/main/java/me/desair/tus/server/checksum/ChecksumRequestHandler.java +++ b/src/main/java/me/desair/tus/server/checksum/ChecksumRequestHandler.java @@ -36,8 +36,8 @@ 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. Therefore we need to revalidate that header here. + // 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. new ChecksumAlgorithmValidator() .validate(method, servletRequest, uploadStorageService, ownerKey); @@ -60,7 +60,7 @@ public void process( } else if (uploadStorageService.isUploadDeduplicationEnabled()) { UploadInfo uploadInfo = uploadStorageService.getUploadInfo( - Utils.getUploadURI(servletRequest, servletResponse), ownerKey); + Utils.getUploadUri(servletRequest, servletResponse), ownerKey); if (uploadInfo != null && !uploadInfo.isUploadInProgress() && uploadInfo.getDuplicatesUploadId() == null) { 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..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,6 +6,7 @@ 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.upload.UploadInfo; @@ -44,6 +45,19 @@ public void process( UploadStorageService uploadStorageService, String ownerKey) throws IOException, TusException { + process(method, servletRequest, servletResponse, uploadStorageService, null, ownerKey, null); + } + + @Override + public HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService lockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { boolean found = true; UploadInfo uploadInfo = @@ -54,14 +68,15 @@ public void process( } else if (uploadInfo.isUploadInProgress()) { try { InputStream stream = servletRequest.getContentInputStream(); - UploadLockingService lockingService = - (UploadLockingService) - servletRequest.getAttribute("me.desair.tus.uploadLockingService"); + + // 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() @@ -99,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/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..21c7699e --- /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(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/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 493f61f1..241595af 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, @@ -56,10 +63,6 @@ public void process( servletResponse.setHeader(HttpHeader.CONTENT_TYPE, info.getFileMimeType()); - 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/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/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/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..09194968 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java @@ -0,0 +1,100 @@ +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.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; +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 + public boolean mustReprocessOnError(HttpMethod method, ProtocolVersion version) { + 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 RufhResponseHeadersHandler()); + 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..1030c87a --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandler.java @@ -0,0 +1,92 @@ +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.HttpProblemDetails; +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 HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + 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 null; + } + + 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_OFFSET, String.valueOf(uploadInfo.getOffset())); + servletResponse.setHeader( + HttpHeader.UPLOAD_COMPLETE, StructuredHeaderUtil.formatBoolean(isFinished)); + + if (isFinished) { + servletResponse.setStatus(200); + } else { + servletResponse.setStatus(204); + } + return null; + } + + private boolean isUploadCompleted(UploadInfo uploadInfo) { + 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 new file mode 100644 index 00000000..95144d92 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java @@ -0,0 +1,151 @@ +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.HttpProblemDetails; +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 HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + 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 null; + } + + 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; + } + } + + 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); + } + return null; + } + + 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.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) { + 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..e7522c84 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java @@ -0,0 +1,49 @@ +package me.desair.tus.server.rufh.handler; + +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.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 HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + + String requestUri = servletRequest.getRequestURI(); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); + + if (uploadInfo != null) { + 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 new file mode 100644 index 00000000..8848b291 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java @@ -0,0 +1,83 @@ +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.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.UploadDigestMismatchException; +import me.desair.tus.server.exception.UploadOffsetMismatchException; +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 HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + + if (exception instanceof UploadOffsetMismatchException) { + // 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; + + return HttpProblemDetails.forOffsetMismatch(expectedOffset, provided); + + } 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(); + } 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/rufh/handler/RufhHeadRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java new file mode 100644 index 00000000..c54ee9fe --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandler.java @@ -0,0 +1,78 @@ +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.HttpProblemDetails; +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.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 HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + + String requestUri = servletRequest.getRequestURI(); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); + + servletResponse.setStatus(204); + 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"); + return null; + } + + private void addUploadLimitHeader( + TusServletResponse response, UploadStorageService uploadStorageService) { + 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..dec452b5 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandler.java @@ -0,0 +1,71 @@ +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.HttpProblemDetails; +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.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 HttpProblemDetails process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + TusException exception) + throws IOException, TusException { + + servletResponse.setHeader( + HttpHeader.ACCEPT_PATCH, + HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD + ", application/offset+octet-stream"); + + addUploadLimitHeader(servletResponse, uploadStorageService); + servletResponse.setStatus(HttpServletResponse.SC_NO_CONTENT); + return null; + } + + 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/RufhResponseHeadersHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhResponseHeadersHandler.java new file mode 100644 index 00000000..7296a77a --- /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.HttpProblemDetails; +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 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/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..d6791c05 --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java @@ -0,0 +1,120 @@ +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.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; +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 UploadAlreadyCompletedException("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 UploadOffsetMismatchException( + "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 InconsistentUploadLengthException( + "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..02da54bb --- /dev/null +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java @@ -0,0 +1,75 @@ +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.InconsistentUploadLengthException; +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 InconsistentUploadLengthException( + "The provided Upload-Length (" + + uploadLength + + ") does not match Content-Length (" + + contentLength + + ")"); + } + } + + long maxUploadSize = uploadStorageService.getMaxUploadSize(); + if (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/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/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 02c59da1..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 @@ -46,6 +46,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; @@ -78,6 +79,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; @@ -453,13 +468,17 @@ private Path getChecksumPath(String checksum, ChecksumAlgorithm algorithm) throw String filename = checksum; if (filename != null) { if (!filename.matches("^[0-9a-fA-F]+$")) { - 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)); + 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(); } - filename = sb.toString(); + } catch (Exception e) { + // Ignore, keep original filename } } } 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 63ca0da8..c6dc66bc 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,13 @@ 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.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; /** Abstract class to implement a tus extension using validators and request handlers. */ @@ -33,9 +36,26 @@ 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)) { + if (requestValidator.supports(method, version)) { requestValidator.validate(method, servletRequest, uploadStorageService, ownerKey); } } @@ -49,11 +69,41 @@ 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)) { + if (requestHandler.supports(method, version)) { requestHandler.process( - method, servletRequest, servletResponse, uploadStorageService, ownerKey); + method, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + ownerKey, + null); } } } @@ -66,11 +116,54 @@ public void handleError( UploadStorageService uploadStorageService, String ownerKey) throws IOException, TusException { + HttpProblemDetails pd = + handleError( + method, + request, + response, + uploadStorageService, + null, + ownerKey, + ProtocolVersion.TUS_1_0_0, + null); + if (pd != null) { + pd.writeTo(response); + } + } + + @Override + public HttpProblemDetails handleError( + HttpMethod method, + TusServletRequest request, + TusServletResponse response, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version, + TusException exception) + throws IOException, TusException { + + if (!isApplicable(method, version)) { + return null; + } + HttpProblemDetails problemDetails = null; for (RequestHandler requestHandler : requestHandlers) { - if (requestHandler.supports(method) && requestHandler.isErrorHandler()) { - requestHandler.process(method, request, response, uploadStorageService, ownerKey); + if (requestHandler.supports(method, version) && requestHandler.isErrorHandler()) { + HttpProblemDetails pd = + requestHandler.process( + method, + request, + response, + uploadStorageService, + uploadLockingService, + ownerKey, + exception); + if (pd != null) { + problemDetails = pd; + } } } + return problemDetails; } } 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..a531a261 --- /dev/null +++ b/src/main/java/me/desair/tus/server/util/StructuredHeaderUtil.java @@ -0,0 +1,190 @@ +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(); + } + + /** + * 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 4044712c..0f7e2380 100644 --- a/src/main/java/me/desair/tus/server/util/Utils.java +++ b/src/main/java/me/desair/tus/server/util/Utils.java @@ -6,7 +6,6 @@ import static java.nio.file.StandardOpenOption.WRITE; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.ObjectOutput; @@ -17,10 +16,12 @@ import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.file.Path; +import java.util.EnumSet; import java.util.LinkedList; 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; @@ -201,8 +202,26 @@ public static ChecksumInfo parseUploadChecksumHeader(HttpServletRequest request) return null; } - public static String getUploadURI(HttpServletRequest request, HttpServletResponse response) { - String location = response.getHeader(HttpHeader.LOCATION); - return StringUtils.isNotBlank(location) ? location : request.getRequestURI(); + /** + * 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(TusServletRequest request, TusServletResponse response) { + HttpMethod method = + request != null + ? HttpMethod.getMethodIfSupported(request, EnumSet.allOf(HttpMethod.class)) + : null; + + if (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method)) { + return response != null ? response.getHeader(HttpHeader.LOCATION) : null; + } else if (request != null) { + return request.getRequestURI(); + } + + return null; } } 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..e08a73ae --- /dev/null +++ b/src/test/java/me/desair/tus/server/CoverageGapTest.java @@ -0,0 +1,527 @@ +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; + +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 handleError8Called = 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 HttpProblemDetails handleError( + HttpMethod method, + TusServletRequest request, + TusServletResponse response, + UploadStorageService uploadStorageService, + UploadLockingService uploadLockingService, + String ownerKey, + ProtocolVersion version, + TusException exception) + throws IOException, TusException { + 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); + } + }); + } + } + + @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, 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.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 + 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)); + } + + @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)); + } + + 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); + + // 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"); + } +} diff --git a/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java b/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java new file mode 100644 index 00000000..52735339 --- /dev/null +++ b/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java @@ -0,0 +1,189 @@ +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.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\"}")); + } + + @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/ITTusFileUploadService.java b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java index b9e84ab8..862082e1 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)); @@ -108,13 +109,17 @@ public void testSupportedHttpMethods() { "download", "expiration", "concatenation", - "cors")); + "cors", + "resumable-uploads-for-http", + "http-digests")); } @Test 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/TusFileUploadServiceTest.java b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java index bdd3066d..2031fde2 100644 --- a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java +++ b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java @@ -1,13 +1,19 @@ 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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; 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 { @@ -164,6 +170,221 @@ 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)); + } + + @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()); + } + @Test public void testDisableCreationWithUploadWhenCreationDisabled() throws Exception { TusFileUploadService service = new TusFileUploadService(); @@ -175,7 +396,8 @@ public void testDisableCreationWithUploadWhenCreationDisabled() throws Exception service.disableTusExtension("creation-with-upload"); } catch (Exception e) { fail( - "Should not throw exception when disabling creation-with-upload when creation is not enabled"); + "Should not throw exception when disabling creation-with-upload when creation is not" + + " enabled"); } } } 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/CorePatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java index 5eed4834..95a14177 100644 --- a/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/core/CorePatchRequestHandlerTest.java @@ -167,17 +167,56 @@ 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, 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/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/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("")); + } +} 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 new file mode 100644 index 00000000..8e2e22b6 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocolTest.java @@ -0,0 +1,35 @@ +package me.desair.tus.server.rufh; + +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. */ +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); + // 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/rufh/RufhProtocolAppendTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java new file mode 100644 index 00000000..12f71fd1 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java @@ -0,0 +1,376 @@ +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); + + me.desair.tus.server.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")); + 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..dc4654f8 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCancellationTest.java @@ -0,0 +1,72 @@ +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.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 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)); + assertThat(response.getHeader(HttpHeader.UPLOAD_DRAFT), is("11")); + 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..31b391b1 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhAppendPatchRequestHandlerTest.java @@ -0,0 +1,212 @@ +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", + null); + + 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)); + } + + @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", + null); + + // 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", + null); + + 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, + null, + "owner", + null); + + 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", + 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 new file mode 100644 index 00000000..0af73d61 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java @@ -0,0 +1,414 @@ +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", + null); + + 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", + null); + + 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", + null); + + 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", + null); + + // 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, + null, + "owner", + null); + + 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", + null); + + 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", + null); + + 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", + null); + + 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", + null); + + 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", + null); + + // 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", + null); + + 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", + 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 new file mode 100644 index 00000000..88508499 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java @@ -0,0 +1,88 @@ +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", + null); + + 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, + 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 new file mode 100644 index 00000000..6a2199f7 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java @@ -0,0 +1,357 @@ +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.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.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); + + 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")); + 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); + + 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")); + } + + @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); + + 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")); + } + + @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); + + 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 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); + + 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")); + } + + @Test + public void testProcessErrorHandler400NullUploadInfo() throws Exception { + request.setRequestURI("/files/null-id"); + when(storageService.getUploadInfo("/files/null-id", "owner")).thenReturn(null); + + 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 new file mode 100644 index 00000000..4d6a08d4 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadRequestHandlerTest.java @@ -0,0 +1,183 @@ +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", + null); + + 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", + null); + + assertThat(response.getStatus(), is(204)); + 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", + null); + + 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", + 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 new file mode 100644 index 00000000..dc7821c2 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhOptionsRequestHandlerTest.java @@ -0,0 +1,138 @@ +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", + null); + + assertThat(response.getStatus(), is(204)); + assertThat( + response.getHeader(HttpHeader.ACCEPT_PATCH), + is("application/partial-upload, application/offset+octet-stream")); + 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, + null, + "owner", + null); + + 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", + null); + + 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", + 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")); + } +} 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")); + } +} 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..d52a8450 --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java @@ -0,0 +1,213 @@ +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"); + } + + @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 new file mode 100644 index 00000000..6476694d --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java @@ -0,0 +1,150 @@ +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; + +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); + } + + @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/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..835c378c --- /dev/null +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhSafePathValidatorTest.java @@ -0,0 +1,60 @@ +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); + } + + @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/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 38b85347..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 @@ -801,6 +801,18 @@ public void testTerminateUploadWithUnsafeChecksums() throws Exception { }); } + @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)); + } + @Test public void testGetUploadInfoByChecksumWithNullValues() throws Exception { // 1. null checksum should return 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 new file mode 100644 index 00000000..f0994bbb --- /dev/null +++ b/src/test/java/me/desair/tus/server/util/StructuredHeaderUtilTest.java @@ -0,0 +1,102 @@ +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 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")); + 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..d03cafd1 100644 --- a/src/test/java/me/desair/tus/server/util/UtilsTest.java +++ b/src/test/java/me/desair/tus/server/util/UtilsTest.java @@ -297,6 +297,31 @@ 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"); + + when(request.getMethod()).thenReturn("POST"); + assertThat(Utils.getUploadUri(request, response), is("/files/location")); + when(request.getMethod()).thenReturn("PUT"); + assertThat(Utils.getUploadUri(request, response), is("/files/location")); + when(request.getMethod()).thenReturn("PATCH"); + assertThat(Utils.getUploadUri(request, response), is("/files/123")); + when(request.getMethod()).thenReturn("GET"); + assertThat(Utils.getUploadUri(request, response), is("/files/123")); + when(request.getMethod()).thenReturn("HEAD"); + assertThat(Utils.getUploadUri(request, response), is("/files/123")); + + when(request.getMethod()).thenReturn("POST"); + assertThat(Utils.getUploadUri(request, null), is(nullValue())); + when(request.getMethod()).thenReturn("PATCH"); + assertThat(Utils.getUploadUri(null, response), is(nullValue())); + } + /** Simple serializable class for testing. */ public static class TestSerializable implements Serializable { private static final long serialVersionUID = 1L;