From 79cd68dac74446ad371aaf7d7f407d8c54332633 Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Wed, 8 Jul 2026 10:28:38 +0200 Subject: [PATCH 1/3] feat: reject uploads with a synchronous validator The pre-made upload handlers offered no supported way to refuse an upload while it was being received; only a fully custom `UploadHandler` could call `UploadEvent.reject(...)`, and the handlers invoked the success callback unconditionally and left rejected files on disk. Add `UploadValidator`, invoked synchronously by the pre-made handlers in three phases: `validateMetadata` before any data is read, `validateHeader` over the first N bytes (e.g. magic-byte checks, aborting after only those bytes), and `validateComplete` over the fully received content (e.g. antivirus). Calling `UploadEvent.reject(...)` from any phase aborts the upload before it is stored, skips the success callback, deletes any written file, and reports the rejection to the client. Register validators additively via `withValidator(...)` or the fluent `validateMetadata`/`validateHeader(int, ...)`/`validateComplete` methods. Fixes #24923 --- .../streams/AbstractFileUploadHandler.java | 84 ++- .../server/streams/AbstractUploadHandler.java | 262 ++++++++++ .../streams/ByteArrayUploadContent.java | 55 ++ .../server/streams/FileUploadContent.java | 84 +++ .../server/streams/InMemoryUploadHandler.java | 56 +- .../streams/UploadCompleteCallback.java | 46 ++ .../flow/server/streams/UploadContent.java | 79 +++ .../server/streams/UploadHeaderCallback.java | 47 ++ .../streams/UploadMetadataCallback.java | 42 ++ .../streams/UploadRejectedException.java | 43 ++ .../flow/server/streams/UploadValidator.java | 146 ++++++ .../communication/UploadHandlerTest.java | 37 ++ .../streams/UploadTransferProgressTest.java | 492 ++++++++++++++++++ 13 files changed, 1445 insertions(+), 28 deletions(-) create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractUploadHandler.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/ByteArrayUploadContent.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/FileUploadContent.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/UploadCompleteCallback.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/UploadContent.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/UploadHeaderCallback.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/UploadMetadataCallback.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/UploadRejectedException.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/streams/UploadValidator.java diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractFileUploadHandler.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractFileUploadHandler.java index 89a3bceab37..98898b6a4e0 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractFileUploadHandler.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractFileUploadHandler.java @@ -20,6 +20,9 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.nio.file.Files; + +import org.slf4j.LoggerFactory; import com.vaadin.flow.server.communication.TransferUtil; @@ -32,8 +35,7 @@ * @since 24.8 */ public abstract class AbstractFileUploadHandler - extends TransferProgressAwareHandler - implements UploadHandler { + extends AbstractUploadHandler { private final FileUploadCallback successCallback; private final FileFactory fileFactory; @@ -57,22 +59,64 @@ public void handleUploadRequest(UploadEvent event) throws IOException { UploadMetadata metadata = new UploadMetadata(event.getFileName(), event.getContentType(), event.getFileSize()); setTransferUI(event.getUI()); - File file; + File file = null; try { - file = fileFactory.createFile(metadata); - try (InputStream inputStream = event.getInputStream(); - FileOutputStream outputStream = new FileOutputStream( - file)) { - TransferUtil.transfer(inputStream, outputStream, - getTransferContext(event), getListeners()); + runMetadataValidators(event); + if (!event.isRejected()) { + try (InputStream raw = event.getInputStream()) { + InputStream in = applyHeaderValidators(event, raw); + if (!event.isRejected()) { + // Create the file only once metadata and header passed, + // so a rejection leaves no file behind. + file = fileFactory.createFile(metadata); + try (FileOutputStream outputStream = new FileOutputStream( + file)) { + TransferUtil.transfer(in, outputStream, + getTransferContext(event), getListeners()); + } + } + } } } catch (IOException e) { - notifyError(event, e); + deleteAndNotify(event, file, e); + throw e; + } catch (RuntimeException e) { + // A validator may throw an unchecked exception; still clean up. + deleteQuietly(file); throw e; } + if (hasValidators() && file != null && !event.isRejected()) { + // Whole-content validation. Its streams are closed (try-with- + // resources) before any deletion, so a validator leaving one open + // can't block deletion. This runs after the transfer's onComplete + // has already fired, so any failure here is reported via onError. + try (FileUploadContent content = new FileUploadContent(file)) { + runCompleteValidators(event, content); + } catch (IOException e) { + deleteAndNotify(event, file, e); + throw e; + } catch (RuntimeException e) { + deleteQuietly(file); + notifyError(event, new IOException(e)); + throw e; + } + } + if (event.isRejected()) { + // A validator rejected the upload; report it as a terminal error so + // progress listeners get a signal. + notifyError(event, + new UploadRejectedException(event.getRejectionMessage())); + } + final File uploadedFile = file; event.getUI().access(() -> { + // A validator may have rejected the upload while it was received; + // discard the written file and skip the callback. + if (event.isRejected()) { + deleteQuietly(uploadedFile); + return; + } try { - successCallback.complete(metadata, file); + successCallback.complete(metadata, uploadedFile); } catch (IOException e) { throw new UncheckedIOException("Error in file upload callback", e); @@ -80,6 +124,24 @@ public void handleUploadRequest(UploadEvent event) throws IOException { }); } + private void deleteAndNotify(UploadEvent event, File file, IOException e) { + deleteQuietly(file); + notifyError(event, e); + } + + private static void deleteQuietly(File file) { + if (file == null || !file.exists()) { + return; + } + try { + Files.delete(file.toPath()); + } catch (IOException e) { + LoggerFactory.getLogger(AbstractFileUploadHandler.class) + .warn("Could not delete the file of a rejected or failed " + + "upload: {}", file.getAbsolutePath(), e); + } + } + @Override protected TransferContext getTransferContext(UploadEvent transferEvent) { return new TransferContext(transferEvent.getRequest(), diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractUploadHandler.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractUploadHandler.java new file mode 100644 index 00000000000..631be45817c --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractUploadHandler.java @@ -0,0 +1,262 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Base class for the pre-made upload handlers, adding support for synchronous + * {@link UploadValidator}s on top of the transfer progress handling. + *

+ * Validators are held here, rather than on + * {@link TransferProgressAwareHandler}, so that they apply only to uploads and + * never to downloads. + * + * @param + * type of the subclass implementing this class, for method chaining + */ +public abstract class AbstractUploadHandler + extends TransferProgressAwareHandler + implements UploadHandler { + + private List validators; + + /** + * Adds a validator that can reject the upload while it is being received. + *

+ * The validator's phase methods are invoked synchronously on the request + * thread; calling {@link UploadEvent#reject(String)} from any of them + * aborts the upload and prevents the success callback from being invoked. + * Validators are invoked in the order they are added; the first rejection + * stops further reading. + * + * @param validator + * the validator to add, not {@code null} + * @return this instance for method chaining + */ + public R withValidator(UploadValidator validator) { + Objects.requireNonNull(validator, "Validator cannot be null"); + if (validators == null) { + validators = new ArrayList<>(2); + } + validators.add(validator); + return (R) this; + } + + /** + * Adds a validator that inspects only the upload metadata, before any data + * is read. + * + * @param callback + * the metadata validation callback, not {@code null} + * @return this instance for method chaining + * @see UploadValidator#validateMetadata(UploadEvent) + */ + public R validateMetadata(UploadMetadataCallback callback) { + Objects.requireNonNull(callback, + "Metadata validation callback cannot be null"); + return withValidator(metadataValidator(callback)); + } + + /** + * Adds a validator that inspects the first {@code maxBytes} bytes of the + * upload, for example a file-signature ("magic byte") or MIME-sniffing + * check. The validator is invoked once with the assembled leading bytes and + * can reject the upload after only those bytes have been read. + * + * @param maxBytes + * the number of leading bytes to inspect, must be positive + * @param callback + * the header validation callback, not {@code null} + * @return this instance for method chaining + * @see UploadValidator#validateHeader(UploadEvent, ByteBuffer) + */ + public R validateHeader(int maxBytes, UploadHeaderCallback callback) { + if (maxBytes <= 0) { + throw new IllegalArgumentException("maxBytes must be positive"); + } + Objects.requireNonNull(callback, + "Header validation callback cannot be null"); + return withValidator(headerValidator(callback, maxBytes)); + } + + /** + * Adds a validator that inspects the fully received upload, before it is + * delivered, for example an antivirus scan. + * + * @param callback + * the complete validation callback, not {@code null} + * @return this instance for method chaining + * @see UploadValidator#validateComplete(UploadEvent, UploadContent) + */ + public R validateComplete(UploadCompleteCallback callback) { + Objects.requireNonNull(callback, + "Complete validation callback cannot be null"); + return withValidator(completeValidator(callback)); + } + + /** + * Runs the metadata phase of the registered validators, before any data is + * read. Stops at the first rejection. + * + * @param event + * the upload being handled + * @throws IOException + * if a validator fails + */ + protected void runMetadataValidators(UploadEvent event) throws IOException { + if (validators == null) { + return; + } + for (UploadValidator validator : validators) { + validator.validateMetadata(event); + if (event.isRejected()) { + return; + } + } + } + + /** + * Runs the header phase of the registered validators: reads the leading + * bytes (up to the largest requested {@link UploadValidator#headerSize()}), + * gives each header validator a read-only view of its first + * {@code headerSize()} bytes, and returns a stream that replays those bytes + * followed by the rest of {@code in}. Returns {@code in} unchanged when no + * header validation is requested or once the upload is rejected. Stops at + * the first rejection. + * + * @param event + * the upload being handled + * @param in + * the upload input stream + * @return the stream to transfer, with the consumed header spliced back in + * @throws IOException + * if reading the header or a validator fails + */ + protected InputStream applyHeaderValidators(UploadEvent event, + InputStream in) throws IOException { + if (validators == null) { + return in; + } + int max = 0; + for (UploadValidator validator : validators) { + max = Math.max(max, Math.max(0, validator.headerSize())); + } + if (max == 0) { + return in; + } + byte[] header = in.readNBytes(max); + for (UploadValidator validator : validators) { + int size = validator.headerSize(); + if (size > 0) { + validator.validateHeader(event, + ByteBuffer + .wrap(header, 0, Math.min(size, header.length)) + .asReadOnlyBuffer()); + if (event.isRejected()) { + return in; + } + } + } + return new SequenceInputStream(new ByteArrayInputStream(header), in); + } + + /** + * Runs the complete phase of the registered validators against the fully + * received content. Stops at the first rejection. + * + * @param event + * the upload being handled + * @param content + * handle to the received content + * @throws IOException + * if a validator fails + */ + protected void runCompleteValidators(UploadEvent event, + UploadContent content) throws IOException { + if (validators == null) { + return; + } + for (UploadValidator validator : validators) { + validator.validateComplete(event, content); + if (event.isRejected()) { + return; + } + } + } + + /** + * Whether any validators are registered. The handlers use this to skip the + * complete-validation phase (and building an {@link UploadContent}) + * entirely when there is nothing to validate. + * + * @return {@code true} if at least one validator is registered + */ + protected boolean hasValidators() { + return validators != null && !validators.isEmpty(); + } + + @Override + protected TransferContext getTransferContext(UploadEvent event) { + return new TransferContext(event.getRequest(), event.getResponse(), + event.getSession(), event.getFileName(), + event.getOwningElement(), event.getFileSize()); + } + + private static UploadValidator metadataValidator( + UploadMetadataCallback callback) { + return new UploadValidator() { + @Override + public void validateMetadata(UploadEvent event) throws IOException { + callback.validate(event); + } + }; + } + + private static UploadValidator headerValidator( + UploadHeaderCallback callback, int maxBytes) { + return new UploadValidator() { + @Override + public void validateHeader(UploadEvent event, ByteBuffer header) + throws IOException { + callback.validate(event, header); + } + + @Override + public int headerSize() { + return maxBytes; + } + }; + } + + private static UploadValidator completeValidator( + UploadCompleteCallback callback) { + return new UploadValidator() { + @Override + public void validateComplete(UploadEvent event, + UploadContent content) throws IOException { + callback.validate(event, content); + } + }; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/ByteArrayUploadContent.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/ByteArrayUploadContent.java new file mode 100644 index 00000000000..0b1c418f7f3 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/ByteArrayUploadContent.java @@ -0,0 +1,55 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Optional; + +/** + * {@link UploadContent} backed by an in-memory {@code byte[]}, used by + * {@link InMemoryUploadHandler} for the complete-validation phase. Reuses the + * existing array (no copy) and holds no OS resource. + */ +class ByteArrayUploadContent implements UploadContent { + + private final byte[] data; + + // Intentionally references the given array without copying: this + // package-private type is constructed only by InMemoryUploadHandler with a + // freshly created array it owns, and never exposes it (getInputStream wraps + // it read-only). Copying would double the memory of the whole upload. + @SuppressWarnings("java:S2384") + ByteArrayUploadContent(byte[] data) { + this.data = data; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(data); + } + + @Override + public long size() { + return data.length; + } + + @Override + public Optional asPath() { + return Optional.empty(); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/FileUploadContent.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/FileUploadContent.java new file mode 100644 index 00000000000..07f4b2665d7 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/FileUploadContent.java @@ -0,0 +1,84 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * {@link UploadContent} backed by an on-disk {@link File}, used by + * {@link AbstractFileUploadHandler} for the complete-validation phase. + *

+ * Tracks every stream it hands out and is {@link AutoCloseable} so the handler + * can close them (via try-with-resources) before deleting the file: a stream + * left open by a validator would otherwise leak a file descriptor and, on + * Windows, prevent deletion of a rejected upload. + */ +class FileUploadContent implements UploadContent, AutoCloseable { + + private final File file; + private final long size; + private final transient List openedStreams = new ArrayList<>( + 1); + + FileUploadContent(File file) { + this.file = file; + this.size = file.length(); + } + + @Override + public InputStream getInputStream() throws IOException { + FileInputStream stream = new FileInputStream(file); + openedStreams.add(stream); + return stream; + } + + @Override + public long size() { + return size; + } + + @Override + public Optional asPath() { + return Optional.of(file.toPath()); + } + + @Override + public void close() throws IOException { + IOException failure = null; + for (InputStream stream : openedStreams) { + try { + stream.close(); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + openedStreams.clear(); + if (failure != null) { + throw failure; + } + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/InMemoryUploadHandler.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/InMemoryUploadHandler.java index 1f78b88923f..26e12aff477 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/streams/InMemoryUploadHandler.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/InMemoryUploadHandler.java @@ -29,8 +29,7 @@ * @since 24.8 */ public class InMemoryUploadHandler - extends TransferProgressAwareHandler - implements UploadHandler { + extends AbstractUploadHandler { private final InMemoryUploadCallback successCallback; public InMemoryUploadHandler(InMemoryUploadCallback successCallback) { @@ -40,36 +39,59 @@ public InMemoryUploadHandler(InMemoryUploadCallback successCallback) { @Override public void handleUploadRequest(UploadEvent event) throws IOException { setTransferUI(event.getUI()); - byte[] data; + byte[] data = null; try { - try (InputStream inputStream = event.getInputStream(); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { - TransferUtil.transfer(inputStream, outputStream, - getTransferContext(event), getListeners()); - data = outputStream.toByteArray(); + runMetadataValidators(event); + if (!event.isRejected()) { + try (InputStream raw = event.getInputStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + InputStream in = applyHeaderValidators(event, raw); + if (!event.isRejected()) { + TransferUtil.transfer(in, outputStream, + getTransferContext(event), getListeners()); + data = outputStream.toByteArray(); + } + } } } catch (IOException e) { notifyError(event, e); throw e; } + if (hasValidators() && data != null && !event.isRejected()) { + // Complete phase runs after the transfer's onComplete has already + // fired, so any failure here is reported via onError. + try { + runCompleteValidators(event, new ByteArrayUploadContent(data)); + } catch (IOException e) { + notifyError(event, e); + throw e; + } catch (RuntimeException e) { + notifyError(event, new IOException(e)); + throw e; + } + } + if (event.isRejected()) { + // A validator rejected the upload; report it as a terminal error so + // progress listeners get a signal. + notifyError(event, + new UploadRejectedException(event.getRejectionMessage())); + } + final byte[] delivered = data; event.getUI().access(() -> { + // A validator may have rejected the upload while it was received; + // the accumulated data must not be delivered in that case. + if (event.isRejected()) { + return; + } try { successCallback.complete( new UploadMetadata(event.getFileName(), event.getContentType(), event.getFileSize()), - data); + delivered); } catch (IOException e) { throw new UncheckedIOException( "Error in memory upload callback", e); } }); } - - @Override - protected TransferContext getTransferContext(UploadEvent transferEvent) { - return new TransferContext(transferEvent.getRequest(), - transferEvent.getResponse(), transferEvent.getSession(), - transferEvent.getFileName(), transferEvent.getOwningElement(), - transferEvent.getFileSize()); - } } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadCompleteCallback.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadCompleteCallback.java new file mode 100644 index 00000000000..e1e2cb3ebcd --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadCompleteCallback.java @@ -0,0 +1,46 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.IOException; +import java.io.Serializable; + +/** + * Callback for validating a fully received upload before it is delivered, used + * with {@link AbstractUploadHandler#validateComplete(UploadCompleteCallback)}. + *

+ * Call {@link UploadEvent#reject(String)} to refuse the upload. See + * {@link UploadValidator#validateComplete(UploadEvent, UploadContent)} for the + * phase semantics; this runs only after the whole upload has been received, so + * it cannot abort reading early. + */ +@FunctionalInterface +public interface UploadCompleteCallback extends Serializable { + + /** + * Validates the fully received upload, rejecting it via + * {@link UploadEvent#reject(String)} if it must not be delivered. + * + * @param event + * the current upload + * @param content + * handle to the received content; only valid for the duration of + * the call + * @throws IOException + * if validation fails; treated as a transfer error + */ + void validate(UploadEvent event, UploadContent content) throws IOException; +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadContent.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadContent.java new file mode 100644 index 00000000000..17687c4b5cd --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadContent.java @@ -0,0 +1,79 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.nio.file.Path; +import java.util.Optional; + +/** + * A handle to a fully received upload, passed to + * {@link UploadValidator#validateComplete(UploadEvent, UploadContent)} so that + * whole-content checks (for example antivirus scanning) can inspect the data + * before it is delivered to the success callback. + *

+ * The content is only valid for the duration of the {@code validateComplete} + * call and must not be retained: for a file-backed upload the underlying file + * may be deleted immediately after the call (for example when the upload is + * rejected), and any {@link #getInputStream() stream} handed out is closed by + * the framework once the call returns. + * + * @see UploadValidator#validateComplete(UploadEvent, UploadContent) + */ +public interface UploadContent extends Serializable { + + /** + * Opens a new stream over the received content. + *

+ * Each call returns an independent stream positioned at the start. The + * framework closes any stream returned here once the enclosing + * {@code validateComplete} call returns, so a validator must read within + * that call and must not retain the stream. When {@link #asPath()} is + * present, prefer it for libraries that scan by file path. + * + * @return a fresh input stream over the content + * @throws IOException + * if the stream cannot be opened + */ + InputStream getInputStream() throws IOException; + + /** + * Returns the size of the received content in bytes. + * + * @return the content size in bytes + */ + long size(); + + /** + * Returns the path of the file backing this content, if the upload was + * stored to a file. + *

+ * Present for file-based upload handlers + * ({@link FileUploadHandler}/{@link TemporaryFileUploadHandler}) and empty + * for in-memory uploads. The path refers to the file produced by the + * handler's {@link FileFactory}; it may be deleted right after + * {@code validateComplete} returns if the upload is rejected. + *

+ * Do not hand this path to an asynchronous or background process: read or + * scan it synchronously within the {@code validateComplete} call, since the + * file may be gone once the call returns. + * + * @return the backing file path, or empty if the content is not file-backed + */ + Optional asPath(); +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadHeaderCallback.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadHeaderCallback.java new file mode 100644 index 00000000000..724392f2e47 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadHeaderCallback.java @@ -0,0 +1,47 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.ByteBuffer; + +/** + * Callback for validating the leading bytes of an upload, used with + * {@link AbstractUploadHandler#validateHeader(int, UploadHeaderCallback)}. + *

+ * Call {@link UploadEvent#reject(String)} to refuse the upload. See + * {@link UploadValidator#validateHeader(UploadEvent, ByteBuffer)} for the phase + * semantics. + */ +@FunctionalInterface +public interface UploadHeaderCallback extends Serializable { + + /** + * Validates the header (leading bytes) of an upload, rejecting it via + * {@link UploadEvent#reject(String)} if it must not be stored. + * + * @param event + * the current upload + * @param header + * a read-only view of the leading bytes of the upload, up to the + * requested size (fewer if the upload is smaller); only valid + * for the duration of the call + * @throws IOException + * if validation fails; treated as a transfer error + */ + void validate(UploadEvent event, ByteBuffer header) throws IOException; +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadMetadataCallback.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadMetadataCallback.java new file mode 100644 index 00000000000..9415ca4b6aa --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadMetadataCallback.java @@ -0,0 +1,42 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.IOException; +import java.io.Serializable; + +/** + * Callback for validating an upload's metadata before any data is read, used + * with {@link AbstractUploadHandler#validateMetadata(UploadMetadataCallback)}. + *

+ * Call {@link UploadEvent#reject(String)} to refuse the upload. See + * {@link UploadValidator#validateMetadata(UploadEvent)} for the phase + * semantics. + */ +@FunctionalInterface +public interface UploadMetadataCallback extends Serializable { + + /** + * Validates the upload metadata, rejecting it via + * {@link UploadEvent#reject(String)} if it must not be stored. + * + * @param event + * the current upload + * @throws IOException + * if validation fails; treated as a transfer error + */ + void validate(UploadEvent event) throws IOException; +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadRejectedException.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadRejectedException.java new file mode 100644 index 00000000000..143e2be9993 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadRejectedException.java @@ -0,0 +1,43 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.IOException; + +/** + * Signals that an upload was rejected by an {@link UploadValidator}. + *

+ * This is passed as the reason to + * {@link TransferProgressListener#onError(TransferContext, IOException)} when a + * validator calls {@link UploadEvent#reject(String)}, so that listeners can + * tell a deliberate rejection apart from a genuine I/O failure. Its message is + * the rejection message supplied to {@link UploadEvent#reject(String)}. + * + * @see UploadValidator + * @see UploadEvent#reject(String) + */ +public class UploadRejectedException extends IOException { + + /** + * Creates a new exception with the given rejection message. + * + * @param message + * the rejection message + */ + public UploadRejectedException(String message) { + super(message); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadValidator.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadValidator.java new file mode 100644 index 00000000000..bdfd2c6a05f --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/UploadValidator.java @@ -0,0 +1,146 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.server.streams; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.ByteBuffer; + +/** + * Validates an upload synchronously while it is being received, allowing it to + * be refused before it is stored. + *

+ * A validator has three lifecycle phases, each invoked on the request thread by + * the pre-made upload handlers ({@link InMemoryUploadHandler}, + * {@link FileUploadHandler}, {@link TemporaryFileUploadHandler}): + *

    + *
  • {@link #validateMetadata(UploadEvent)} once, before any data is read, for + * checks based only on metadata such as file name, content type or declared + * size;
  • + *
  • {@link #validateHeader(UploadEvent, ByteBuffer)} once, over the first + * {@link #headerSize()} bytes of the upload, for checks on the leading content + * such as file-signature ("magic byte") or MIME sniffing;
  • + *
  • {@link #validateComplete(UploadEvent, UploadContent)} once, after the + * whole upload has been received, for whole-content checks such as antivirus + * scanning.
  • + *
+ * Calling {@link UploadEvent#reject(String)} from any phase aborts the upload: + * no further data is read, the success callback is not invoked and any + * partially stored data is cleaned up. Prefer {@code reject(...)} over + * throwing: in a multipart upload a rejection lets the remaining files be + * processed (HTTP 207), whereas a thrown exception aborts the whole request. + *

+ * Validators run synchronously and not wrapped in + * {@link com.vaadin.flow.component.UI#access(com.vaadin.flow.server.Command)}; + * they must only inspect the upload and decide whether to reject it, not + * perform UI updates. Use a {@link TransferProgressListener} for UI updates. + *

+ * For single-phase checks, prefer the fluent handler methods + * ({@link AbstractUploadHandler#validateMetadata(UploadMetadataCallback)} and + * friends) with a lambda. Implement this interface directly to combine multiple + * phases in one validator. + * + * @see AbstractUploadHandler#withValidator(UploadValidator) + * @see UploadEvent#reject(String) + */ +public interface UploadValidator extends Serializable { + + /** + * Validates the upload metadata before any data is read. + *

+ * Invoked once, before the first byte is read, so it can refuse an upload + * (for example by declared size or file name) without reading its body. + * + * @param event + * the current upload + * @throws IOException + * if validation fails; treated as a transfer error + */ + default void validateMetadata(UploadEvent event) throws IOException { + } + + /** + * Validates the header (leading bytes) of the upload, before the rest is + * read. + *

+ * Invoked once, only when {@link #headerSize()} is greater than zero, with + * a read-only view of the first {@code headerSize()} bytes of the upload + * (or fewer, including an empty buffer, if the upload is smaller). + * Rejecting here aborts the upload after only those leading bytes have been + * read. The buffer is only valid for the duration of the call and must not + * be retained. + *

+ * Overriding this method without also overriding + * {@link #headerSize()} to return a positive value has no effect: it is + * never invoked. + * + * @param event + * the current upload + * @param header + * a read-only view of the leading bytes of the upload + * @throws IOException + * if validation fails; treated as a transfer error + */ + default void validateHeader(UploadEvent event, ByteBuffer header) + throws IOException { + } + + /** + * Validates the fully received upload, before it is delivered to the + * success callback. + *

+ * Invoked once, after the whole upload has been received. Because the + * entire body has already been read by this point, this phase + * cannot abort reading early, so it is not a size-limiting + * mechanism. Note that {@link #validateMetadata} can only inspect the + * client-declared size ({@link UploadEvent#getFileSize()}), which is + * untrusted for XHR uploads, and {@link UploadHandler#getFileSizeMax()} + * bounds multipart uploads only — XHR upload size is not enforced by the + * framework. + *

+ * The transfer's {@link TransferProgressListener#onComplete} has already + * fired by the time this runs (it signals that all bytes were received, not + * that the upload was accepted); rejecting or failing here is reported to + * progress listeners as {@link TransferProgressListener#onError}. + * + * @param event + * the current upload + * @param content + * handle to the received content, only valid for the duration of + * the call + * @throws IOException + * if validation fails; treated as a transfer error + */ + default void validateComplete(UploadEvent event, UploadContent content) + throws IOException { + } + + /** + * The number of leading bytes to make available to + * {@link #validateHeader(UploadEvent, ByteBuffer)}. Defaults to {@code 0}, + * meaning the header phase is skipped for this validator; override to a + * positive value to receive the header. + *

+ * Must return a stable value: it is queried more than once per upload. The + * framework buffers this many bytes in memory per upload, so keep it + * modest. + * + * @return the header size in bytes, {@code 0} to skip the header phase + */ + default int headerSize() { + return 0; + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/UploadHandlerTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/UploadHandlerTest.java index 534b2dca7ae..855b3dea16a 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/communication/UploadHandlerTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/UploadHandlerTest.java @@ -84,6 +84,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @NotThreadSafe @@ -851,6 +852,42 @@ void multipartUpload_mixedAcceptReject_returns207WithJson() Mockito.verify(response).setContentType("application/json"); } + @Test + void multipartUpload_inMemoryWithValidator_rejectsOnePart_returns207() + throws IOException, ServletException { + List parts = new ArrayList<>(); + parts.add(createPart(createInputStream("one"), MULTIPART_CONTENT_TYPE, + "file1.png", 3)); + parts.add(createPart(createInputStream("two"), MULTIPART_CONTENT_TYPE, + "file2.zip", 3)); + + when(request.getParts()).thenReturn(parts); + + List processedFiles = new ArrayList<>(); + UploadHandler uploadHandler = UploadHandler + .inMemory((meta, bytes) -> processedFiles.add(meta.fileName())) + .validateMetadata(event -> { + if (event.getFileName().endsWith(".zip")) { + event.reject("ZIP files are not allowed"); + } + }); + + StreamRegistration streamRegistration = streamResourceRegistry + .registerResource(uploadHandler); + AbstractStreamResource res = streamRegistration.getResource(); + + mockRequest(res, "testContent"); + when(request.getContentType()).thenReturn(MULTIPART_CONTENT_TYPE); + when(response.getWriter()).thenReturn(mock(java.io.PrintWriter.class)); + + handler.handleRequest(session, request, response); + + assertEquals(List.of("file1.png"), processedFiles, + "Only the accepted part should reach the callback"); + verify(response).setStatus(207); + verify(response).setContentType("application/json"); + } + @Test void multipartUpload_allRejected_returns422() throws IOException, ServletException { diff --git a/flow-server/src/test/java/com/vaadin/flow/server/streams/UploadTransferProgressTest.java b/flow-server/src/test/java/com/vaadin/flow/server/streams/UploadTransferProgressTest.java index be3dd128939..4486adecd3a 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/streams/UploadTransferProgressTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/streams/UploadTransferProgressTest.java @@ -20,15 +20,20 @@ import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URISyntaxException; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.Random; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; @@ -50,9 +55,16 @@ import com.vaadin.tests.util.AlwaysLockedVaadinSession; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class UploadTransferProgressTest { private static final int DUMMY_CONTENT_LENGTH = 160000; @@ -233,6 +245,486 @@ void transferProgressListener_inMemory_addListener_errorOccured_errorlistenerInv assertEquals(List.of("onStart", "onError"), invocations); } + @Test + void withValidator_rejectsAtStart_inMemory_callbackNotInvoked() + throws IOException { + simulateReject(); + AtomicBoolean completed = new AtomicBoolean(); + List events = new ArrayList<>(); + UploadHandler handler = UploadHandler + .inMemory((meta, bytes) -> completed.set(true), + recordingListener(events)) + .validateMetadata(event -> event.reject("rejected")); + + handler.handleUploadRequest(uploadEvent); + + assertFalse(completed.get(), + "Success callback must not run for a rejected upload"); + assertTrue(uploadEvent.isRejected()); + // Rejected before the transfer starts, so no onStart; the rejection is + // still reported as a terminal onError. + assertEquals(List.of("onError:UploadRejectedException"), events); + } + + @Test + void withValidator_rejectsAtStart_toFile_noFileCreatedAndCallbackNotInvoked() + throws IOException { + simulateReject(); + AtomicReference createdFile = new AtomicReference<>(); + AtomicBoolean completed = new AtomicBoolean(); + List events = new ArrayList<>(); + UploadHandler handler = UploadHandler + .toFile((meta, file) -> completed.set(true), + capturingFileFactory(createdFile), + recordingListener(events)) + .validateMetadata(event -> event.reject("rejected")); + + handler.handleUploadRequest(uploadEvent); + + assertFalse(completed.get(), + "Success callback must not run for a rejected upload"); + assertNull(createdFile.get(), + "No file should be created for a metadata-rejected upload"); + assertEquals(List.of("onError:UploadRejectedException"), events); + } + + @Test + void validateComplete_throwsIOException_toFile_fileDeletedAndErrorReported() { + AtomicReference createdFile = new AtomicReference<>(); + AtomicBoolean completed = new AtomicBoolean(); + List events = new ArrayList<>(); + UploadHandler handler = UploadHandler + .toFile((meta, file) -> completed.set(true), + capturingFileFactory(createdFile), + recordingListener(events)) + .validateComplete((event, content) -> { + throw new IOException("Validation failed"); + }); + + IOException thrown = assertThrows(IOException.class, + () -> handler.handleUploadRequest(uploadEvent)); + assertEquals("Validation failed", thrown.getMessage()); + + assertFalse(completed.get(), + "Success callback must not run when validation fails"); + assertFalse(createdFile.get().exists(), + "File should be deleted when validation throws"); + assertTrue(events.contains("onError:IOException"), + "A validator IOException should be reported via onError"); + } + + @Test + void withValidator_multipleValidators_firstRejectionShortCircuits() + throws IOException { + simulateReject(); + AtomicBoolean secondInvoked = new AtomicBoolean(); + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).validateMetadata(event -> event.reject("rejected")) + .validateMetadata(event -> secondInvoked.set(true)); + + handler.handleUploadRequest(uploadEvent); + + assertFalse(secondInvoked.get(), + "A later validator must not run once an earlier one rejects"); + } + + @Test + void validateHeader_rejects_stopsReadingEarly() throws IOException { + AtomicBoolean rejected = simulateReject(); + AtomicInteger bytesRead = new AtomicInteger(); + InputStream counting = new FilterInputStream( + createRandomBytes(DUMMY_CONTENT_LENGTH)) { + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n > 0) { + bytesRead.addAndGet(n); + } + return n; + } + }; + when(uploadEvent.getInputStream()).thenReturn(counting); + AtomicBoolean completed = new AtomicBoolean(); + UploadHandler handler = UploadHandler + .inMemory((meta, bytes) -> completed.set(true)) + .validateHeader(4, (event, header) -> event.reject("bad")); + + handler.handleUploadRequest(uploadEvent); + + assertFalse(completed.get(), + "Success callback must not run for a rejected upload"); + assertTrue(rejected.get()); + // Only the 4 header bytes are read; the rest of the upload is not. + assertEquals(4, bytesRead.get(), + "Reading should stop after the header is validated"); + } + + @Test + void validateComplete_throwsRuntimeException_toFile_fileDeleted() { + AtomicReference createdFile = new AtomicReference<>(); + AtomicBoolean completed = new AtomicBoolean(); + List events = new ArrayList<>(); + UploadHandler handler = UploadHandler + .toFile((meta, file) -> completed.set(true), + capturingFileFactory(createdFile), + recordingListener(events)) + .validateComplete((event, content) -> { + throw new IllegalStateException("Validation blew up"); + }); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> handler.handleUploadRequest(uploadEvent)); + assertEquals("Validation blew up", thrown.getMessage()); + + assertFalse(completed.get(), + "Success callback must not run when validation fails"); + assertFalse(createdFile.get().exists(), + "File should be deleted when validation throws"); + // An unchecked failure is not reported via onError. + // The transfer's onComplete already fired, so a complete-phase failure + // is reported via onError to avoid a false "success" signal. + assertTrue(events.contains("onError:IOException"), + "A complete-phase failure should be reported via onError"); + } + + @Test + void withValidator_accepts_uploadCompletes() throws IOException { + AtomicReference received = new AtomicReference<>(); + UploadHandler handler = UploadHandler + .inMemory((meta, bytes) -> received.set(bytes)) + .validateMetadata(event -> { + // accept everything + }); + + handler.handleUploadRequest(uploadEvent); + + assertNotNull(received.get(), + "Accepted upload should reach the success callback"); + assertEquals(DUMMY_CONTENT_LENGTH, received.get().length); + } + + @Test + void validateComplete_rejectsInMemory_callbackNotInvoked() + throws IOException { + simulateReject(); + AtomicBoolean completed = new AtomicBoolean(); + long[] seenSize = { 0 }; + UploadHandler handler = UploadHandler + .inMemory((meta, bytes) -> completed.set(true)) + .validateComplete((event, content) -> { + seenSize[0] = content.size(); + assertTrue(content.asPath().isEmpty(), + "In-memory content has no path"); + try (InputStream in = content.getInputStream()) { + assertEquals(DUMMY_CONTENT_LENGTH, + in.readAllBytes().length); + } + event.reject("infected"); + }); + + handler.handleUploadRequest(uploadEvent); + + assertFalse(completed.get(), + "Success callback must not run for a rejected upload"); + assertTrue(uploadEvent.isRejected()); + assertEquals(DUMMY_CONTENT_LENGTH, seenSize[0]); + } + + @Test + void validateComplete_rejectsFile_fileDeletedAndPathPresent() + throws IOException { + simulateReject(); + AtomicReference createdFile = new AtomicReference<>(); + AtomicBoolean completed = new AtomicBoolean(); + AtomicBoolean pathPresent = new AtomicBoolean(); + UploadHandler handler = UploadHandler + .toFile((meta, file) -> completed.set(true), + capturingFileFactory(createdFile)) + .validateComplete((event, content) -> { + pathPresent.set(content.asPath().isPresent()); + event.reject("infected"); + }); + + handler.handleUploadRequest(uploadEvent); + + assertFalse(completed.get(), + "Success callback must not run for a rejected upload"); + assertTrue(pathPresent.get(), + "File-backed content should expose a path"); + assertFalse(createdFile.get().exists(), + "Rejected upload file should be deleted"); + } + + @Test + void validateComplete_notInvoked_whenRejectedDuringHeader() + throws IOException { + simulateReject(); + AtomicBoolean completeRan = new AtomicBoolean(); + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).validateHeader(4, (event, header) -> event.reject("bad")) + .validateComplete((event, content) -> completeRan.set(true)); + + handler.handleUploadRequest(uploadEvent); + + assertTrue(uploadEvent.isRejected()); + assertFalse(completeRan.get(), + "Complete phase must not run when rejected during the header"); + } + + @Test + void emptyUpload_headerInvokedWithEmpty_completeInvoked() + throws IOException { + when(uploadEvent.getInputStream()) + .thenReturn(new ByteArrayInputStream(new byte[0])); + AtomicBoolean headerRan = new AtomicBoolean(); + int[] headerRemaining = { -1 }; + AtomicBoolean completeRan = new AtomicBoolean(); + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).validateHeader(8, (event, header) -> { + headerRan.set(true); + headerRemaining[0] = header.remaining(); + }).validateComplete((event, content) -> completeRan.set(true)); + + handler.handleUploadRequest(uploadEvent); + + assertTrue(headerRan.get(), + "Header validator must run even for a 0-byte upload"); + assertEquals(0, headerRemaining[0], + "Header of a 0-byte upload should be empty"); + assertTrue(completeRan.get(), + "Complete validator must run even for a 0-byte upload"); + } + + @Test + void validateHeader_invokedExactlyOnce_onMultiChunkUpload() + throws IOException { + AtomicInteger headerCount = new AtomicInteger(); + long[] seenBytes = { 0 }; + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).validateHeader(8, (event, header) -> { + headerCount.incrementAndGet(); + seenBytes[0] = header.remaining(); + }); + + handler.handleUploadRequest(uploadEvent); + + assertEquals(1, headerCount.get(), + "Header validator should run exactly once"); + assertEquals(8, seenBytes[0], + "Header validator should see the requested number of bytes"); + } + + @Test + void validateHeader_budgetExceedsContent_seesFullContent() + throws IOException { + long[] seenBytes = { -1 }; + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).validateHeader(DUMMY_CONTENT_LENGTH * 2, + (event, header) -> seenBytes[0] = header.remaining()); + + handler.handleUploadRequest(uploadEvent); + + assertEquals(DUMMY_CONTENT_LENGTH, seenBytes[0], + "Header larger than the upload should see the full content"); + } + + @Test + void validateHeader_seesExpectedLeadingBytes() throws IOException { + byte[] content = deterministicBytes(100); + when(uploadEvent.getInputStream()) + .thenReturn(new ByteArrayInputStream(content)); + byte[][] seen = new byte[1][]; + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).validateHeader(8, (event, header) -> seen[0] = toArray(header)); + + handler.handleUploadRequest(uploadEvent); + + assertArrayEquals(Arrays.copyOf(content, 8), seen[0]); + } + + @Test + void validateHeader_multipleValidators_eachSeesOwnSize() + throws IOException { + byte[] content = deterministicBytes(100); + when(uploadEvent.getInputStream()) + .thenReturn(new ByteArrayInputStream(content)); + byte[][] four = new byte[1][]; + byte[][] eight = new byte[1][]; + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).validateHeader(4, (event, header) -> four[0] = toArray(header)) + .validateHeader(8, + (event, header) -> eight[0] = toArray(header)); + + handler.handleUploadRequest(uploadEvent); + + assertArrayEquals(Arrays.copyOf(content, 4), four[0]); + assertArrayEquals(Arrays.copyOf(content, 8), eight[0]); + } + + @Test + void validateHeader_shortReads_accumulatesAcrossChunks() + throws IOException { + byte[] content = deterministicBytes(100); + // Force reads of at most 3 bytes so the 8-byte header spans chunks. + InputStream shortReader = new FilterInputStream( + new ByteArrayInputStream(content)) { + @Override + public int read(byte[] b, int off, int len) throws IOException { + return super.read(b, off, Math.min(len, 3)); + } + }; + when(uploadEvent.getInputStream()).thenReturn(shortReader); + byte[][] seen = new byte[1][]; + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).validateHeader(8, (event, header) -> seen[0] = toArray(header)); + + handler.handleUploadRequest(uploadEvent); + + assertArrayEquals(Arrays.copyOf(content, 8), seen[0]); + } + + @Test + void validateHeader_accepted_fullContentDelivered() throws IOException { + byte[] content = deterministicBytes(100); + when(uploadEvent.getInputStream()) + .thenReturn(new ByteArrayInputStream(content)); + AtomicReference received = new AtomicReference<>(); + long[] seenHeader = { -1 }; + UploadHandler handler = UploadHandler + .inMemory((meta, bytes) -> received.set(bytes)) + .validateHeader(8, + (event, header) -> seenHeader[0] = header.remaining()); + + handler.handleUploadRequest(uploadEvent); + + assertEquals(8, seenHeader[0]); + assertArrayEquals(content, received.get(), + "Header + remainder must be delivered to the callback intact"); + } + + @Test + void validateHeader_rejects_toFile_noFileCreated() throws IOException { + simulateReject(); + AtomicReference createdFile = new AtomicReference<>(); + AtomicBoolean completed = new AtomicBoolean(); + UploadHandler handler = UploadHandler + .toFile((meta, file) -> completed.set(true), + capturingFileFactory(createdFile)) + .validateHeader(4, (event, header) -> event.reject("bad")); + + handler.handleUploadRequest(uploadEvent); + + assertFalse(completed.get(), + "Success callback must not run for a rejected upload"); + assertNull(createdFile.get(), + "No file should be created for a header-rejected upload"); + } + + @Test + void handWrittenValidator_allPhasesInvokedInOrder() throws IOException { + List phases = new ArrayList<>(); + UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { + }).withValidator(new UploadValidator() { + @Override + public void validateMetadata(UploadEvent event) { + phases.add("metadata"); + } + + @Override + public void validateHeader(UploadEvent event, ByteBuffer header) { + phases.add("header:" + header.remaining()); + } + + @Override + public void validateComplete(UploadEvent event, + UploadContent content) { + phases.add("complete"); + } + + @Override + public int headerSize() { + return 8; + } + }); + + handler.handleUploadRequest(uploadEvent); + + assertEquals(List.of("metadata", "header:8", "complete"), phases); + } + + private static byte[] deterministicBytes(int size) { + byte[] bytes = new byte[size]; + for (int i = 0; i < size; i++) { + bytes[i] = (byte) i; + } + return bytes; + } + + private static byte[] toArray(ByteBuffer buffer) { + byte[] array = new byte[buffer.remaining()]; + buffer.get(array); + return array; + } + + /** + * Makes the mocked {@link UploadEvent} honor {@code reject()} so that + * {@code isRejected()} reflects a rejection performed by a validator. + * + * @return flag that becomes {@code true} once the upload is rejected + */ + private AtomicBoolean simulateReject() { + AtomicBoolean rejected = new AtomicBoolean(); + Mockito.doAnswer(invocation -> { + rejected.set(true); + return null; + }).when(uploadEvent).reject(Mockito.anyString()); + Mockito.doAnswer(invocation -> { + rejected.set(true); + return null; + }).when(uploadEvent).reject(); + when(uploadEvent.isRejected()).thenAnswer(invocation -> rejected.get()); + when(uploadEvent.getRejectionMessage()).thenReturn("rejected"); + return rejected; + } + + private FileFactory capturingFileFactory( + AtomicReference createdFile) { + return metadata -> { + File file = assertDoesNotThrow(() -> Files + .createFile(temporaryFolder.resolve(DUMMY_FILE_NAME)) + .toFile()); + createdFile.set(file); + return file; + }; + } + + private static TransferProgressListener recordingListener( + List events) { + return new TransferProgressListener() { + @Override + public void onStart(TransferContext context) { + events.add("onStart"); + } + + @Override + public void onProgress(TransferContext context, + long transferredBytes, long totalBytes) { + events.add("onProgress"); + } + + @Override + public void onComplete(TransferContext context, + long transferredBytes) { + events.add("onComplete"); + } + + @Override + public void onError(TransferContext context, IOException reason) { + events.add("onError:" + reason.getClass().getSimpleName()); + } + }; + } + private ByteArrayInputStream createRandomBytes(int size) { byte[] bytes = new byte[size]; new Random().nextBytes(bytes); From d624cdcc2695c0a1da14a08a7ee16fdd8d8d76cf Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Thu, 9 Jul 2026 05:20:10 +0000 Subject: [PATCH 2/3] test: use a real UploadEvent instead of a mock Constructing a real UploadEvent lets the rejection state machine (reject/isRejected/getRejectionMessage) run for real, removing the simulateReject() helper that stubbed those on the mock. Upload content is now served through a mocked Part. --- .../streams/UploadTransferProgressTest.java | 74 +++++-------------- 1 file changed, 20 insertions(+), 54 deletions(-) diff --git a/flow-server/src/test/java/com/vaadin/flow/server/streams/UploadTransferProgressTest.java b/flow-server/src/test/java/com/vaadin/flow/server/streams/UploadTransferProgressTest.java index 4486adecd3a..05729efecef 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/streams/UploadTransferProgressTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/streams/UploadTransferProgressTest.java @@ -17,6 +17,7 @@ import jakarta.servlet.ServletContext; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Part; import java.io.ByteArrayInputStream; import java.io.File; @@ -76,12 +77,13 @@ class UploadTransferProgressTest { private StreamResourceRegistry streamResourceRegistry; private UI ui; private Element element; + private Part part; private UploadEvent uploadEvent; @TempDir Path temporaryFolder; @BeforeEach - void setUp() throws ServletException, ServiceException { + void setUp() throws ServletException, ServiceException, IOException { VaadinService service = new MockVaadinServletService(); session = new AlwaysLockedVaadinSession(service) { @Override @@ -97,18 +99,11 @@ public StreamResourceRegistry getResourceRegistry() { Mockito.when(request.getServletContext()).thenReturn(servletContext); element = Mockito.mock(Element.class); response = Mockito.mock(VaadinResponse.class); - uploadEvent = Mockito.mock(UploadEvent.class); - Mockito.doReturn(DUMMY_FILE_NAME).when(uploadEvent).getFileName(); - Mockito.when(uploadEvent.getContentType()) - .thenReturn("application/octet-stream"); - Mockito.when(uploadEvent.getFileSize()) - .thenReturn((long) DUMMY_CONTENT_LENGTH); - Mockito.when(uploadEvent.getInputStream()) + // The upload content is served through the multipart Part; individual + // tests re-stub this to control the bytes and reads they need. + part = Mockito.mock(Part.class); + Mockito.when(part.getInputStream()) .thenReturn(createRandomBytes(DUMMY_CONTENT_LENGTH)); - Mockito.when(uploadEvent.getResponse()).thenReturn(response); - Mockito.when(uploadEvent.getSession()).thenReturn(session); - Mockito.when(uploadEvent.getOwningElement()).thenReturn(element); - Mockito.when(uploadEvent.getRequest()).thenReturn(request); Component componentOwner = Mockito.mock(Component.class); Mockito.when(element.getComponent()) .thenReturn(Optional.of(componentOwner)); @@ -119,11 +114,11 @@ public StreamResourceRegistry getResourceRegistry() { command.execute(); return null; }).when(ui).access(Mockito.any(Command.class)); - Mockito.when(uploadEvent.getUI()).thenReturn(ui); - Mockito.when(componentOwner.getUI()).thenReturn(Optional.of(ui)); - Mockito.when(uploadEvent.getOwningComponent()) - .thenReturn(componentOwner); + + uploadEvent = new UploadEvent(request, response, session, + DUMMY_FILE_NAME, DUMMY_CONTENT_LENGTH, + "application/octet-stream", element, part); } @Test @@ -195,7 +190,7 @@ void transferProgressListener_toTempFile_addListener_errorOccured_errorlistenerI Mockito.doThrow(new IOException("Test exception")).when(inputStream) .read(Mockito.any(byte[].class), Mockito.anyInt(), Mockito.anyInt()); - Mockito.when(uploadEvent.getInputStream()).thenReturn(inputStream); + Mockito.when(part.getInputStream()).thenReturn(inputStream); UploadHandler handler = UploadHandler.toTempFile((meta, file) -> { }, createErrorTransferProgressListener(invocations)); @@ -232,7 +227,7 @@ void transferProgressListener_inMemory_addListener_errorOccured_errorlistenerInv Mockito.doThrow(new IOException("Test exception")).when(inputStream) .read(Mockito.any(byte[].class), Mockito.anyInt(), Mockito.anyInt()); - Mockito.when(uploadEvent.getInputStream()).thenReturn(inputStream); + Mockito.when(part.getInputStream()).thenReturn(inputStream); UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { }, createErrorTransferProgressListener(invocations)); @@ -248,7 +243,6 @@ void transferProgressListener_inMemory_addListener_errorOccured_errorlistenerInv @Test void withValidator_rejectsAtStart_inMemory_callbackNotInvoked() throws IOException { - simulateReject(); AtomicBoolean completed = new AtomicBoolean(); List events = new ArrayList<>(); UploadHandler handler = UploadHandler @@ -269,7 +263,6 @@ void withValidator_rejectsAtStart_inMemory_callbackNotInvoked() @Test void withValidator_rejectsAtStart_toFile_noFileCreatedAndCallbackNotInvoked() throws IOException { - simulateReject(); AtomicReference createdFile = new AtomicReference<>(); AtomicBoolean completed = new AtomicBoolean(); List events = new ArrayList<>(); @@ -316,7 +309,6 @@ void validateComplete_throwsIOException_toFile_fileDeletedAndErrorReported() { @Test void withValidator_multipleValidators_firstRejectionShortCircuits() throws IOException { - simulateReject(); AtomicBoolean secondInvoked = new AtomicBoolean(); UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { }).validateMetadata(event -> event.reject("rejected")) @@ -330,7 +322,6 @@ void withValidator_multipleValidators_firstRejectionShortCircuits() @Test void validateHeader_rejects_stopsReadingEarly() throws IOException { - AtomicBoolean rejected = simulateReject(); AtomicInteger bytesRead = new AtomicInteger(); InputStream counting = new FilterInputStream( createRandomBytes(DUMMY_CONTENT_LENGTH)) { @@ -343,7 +334,7 @@ public int read(byte[] b, int off, int len) throws IOException { return n; } }; - when(uploadEvent.getInputStream()).thenReturn(counting); + when(part.getInputStream()).thenReturn(counting); AtomicBoolean completed = new AtomicBoolean(); UploadHandler handler = UploadHandler .inMemory((meta, bytes) -> completed.set(true)) @@ -353,7 +344,7 @@ public int read(byte[] b, int off, int len) throws IOException { assertFalse(completed.get(), "Success callback must not run for a rejected upload"); - assertTrue(rejected.get()); + assertTrue(uploadEvent.isRejected()); // Only the 4 header bytes are read; the rest of the upload is not. assertEquals(4, bytesRead.get(), "Reading should stop after the header is validated"); @@ -406,7 +397,6 @@ void withValidator_accepts_uploadCompletes() throws IOException { @Test void validateComplete_rejectsInMemory_callbackNotInvoked() throws IOException { - simulateReject(); AtomicBoolean completed = new AtomicBoolean(); long[] seenSize = { 0 }; UploadHandler handler = UploadHandler @@ -433,7 +423,6 @@ void validateComplete_rejectsInMemory_callbackNotInvoked() @Test void validateComplete_rejectsFile_fileDeletedAndPathPresent() throws IOException { - simulateReject(); AtomicReference createdFile = new AtomicReference<>(); AtomicBoolean completed = new AtomicBoolean(); AtomicBoolean pathPresent = new AtomicBoolean(); @@ -458,7 +447,6 @@ void validateComplete_rejectsFile_fileDeletedAndPathPresent() @Test void validateComplete_notInvoked_whenRejectedDuringHeader() throws IOException { - simulateReject(); AtomicBoolean completeRan = new AtomicBoolean(); UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { }).validateHeader(4, (event, header) -> event.reject("bad")) @@ -474,7 +462,7 @@ void validateComplete_notInvoked_whenRejectedDuringHeader() @Test void emptyUpload_headerInvokedWithEmpty_completeInvoked() throws IOException { - when(uploadEvent.getInputStream()) + when(part.getInputStream()) .thenReturn(new ByteArrayInputStream(new byte[0])); AtomicBoolean headerRan = new AtomicBoolean(); int[] headerRemaining = { -1 }; @@ -531,7 +519,7 @@ void validateHeader_budgetExceedsContent_seesFullContent() @Test void validateHeader_seesExpectedLeadingBytes() throws IOException { byte[] content = deterministicBytes(100); - when(uploadEvent.getInputStream()) + when(part.getInputStream()) .thenReturn(new ByteArrayInputStream(content)); byte[][] seen = new byte[1][]; UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { @@ -546,7 +534,7 @@ void validateHeader_seesExpectedLeadingBytes() throws IOException { void validateHeader_multipleValidators_eachSeesOwnSize() throws IOException { byte[] content = deterministicBytes(100); - when(uploadEvent.getInputStream()) + when(part.getInputStream()) .thenReturn(new ByteArrayInputStream(content)); byte[][] four = new byte[1][]; byte[][] eight = new byte[1][]; @@ -573,7 +561,7 @@ public int read(byte[] b, int off, int len) throws IOException { return super.read(b, off, Math.min(len, 3)); } }; - when(uploadEvent.getInputStream()).thenReturn(shortReader); + when(part.getInputStream()).thenReturn(shortReader); byte[][] seen = new byte[1][]; UploadHandler handler = UploadHandler.inMemory((meta, bytes) -> { }).validateHeader(8, (event, header) -> seen[0] = toArray(header)); @@ -586,7 +574,7 @@ public int read(byte[] b, int off, int len) throws IOException { @Test void validateHeader_accepted_fullContentDelivered() throws IOException { byte[] content = deterministicBytes(100); - when(uploadEvent.getInputStream()) + when(part.getInputStream()) .thenReturn(new ByteArrayInputStream(content)); AtomicReference received = new AtomicReference<>(); long[] seenHeader = { -1 }; @@ -604,7 +592,6 @@ void validateHeader_accepted_fullContentDelivered() throws IOException { @Test void validateHeader_rejects_toFile_noFileCreated() throws IOException { - simulateReject(); AtomicReference createdFile = new AtomicReference<>(); AtomicBoolean completed = new AtomicBoolean(); UploadHandler handler = UploadHandler @@ -666,27 +653,6 @@ private static byte[] toArray(ByteBuffer buffer) { return array; } - /** - * Makes the mocked {@link UploadEvent} honor {@code reject()} so that - * {@code isRejected()} reflects a rejection performed by a validator. - * - * @return flag that becomes {@code true} once the upload is rejected - */ - private AtomicBoolean simulateReject() { - AtomicBoolean rejected = new AtomicBoolean(); - Mockito.doAnswer(invocation -> { - rejected.set(true); - return null; - }).when(uploadEvent).reject(Mockito.anyString()); - Mockito.doAnswer(invocation -> { - rejected.set(true); - return null; - }).when(uploadEvent).reject(); - when(uploadEvent.isRejected()).thenAnswer(invocation -> rejected.get()); - when(uploadEvent.getRejectionMessage()).thenReturn("rejected"); - return rejected; - } - private FileFactory capturingFileFactory( AtomicReference createdFile) { return metadata -> { From c236bfbcd6eb18b6bf9749a2a3046f88b6649e2b Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Thu, 9 Jul 2026 06:02:44 +0000 Subject: [PATCH 3/3] refactor: simplify upload handler request handling Extract the in-memory read phase into a helper so metadata and header rejection become flat early returns instead of nested guards, and drop the redundant post-transfer isRejected() check inside the UI.access command in both handlers: rejection state is fully settled once all validators have run, so a rejected upload reports its terminal onError, cleans up, and returns without scheduling a no-op delivery. --- .../streams/AbstractFileUploadHandler.java | 16 +++--- .../server/streams/InMemoryUploadHandler.java | 55 ++++++++++++------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractFileUploadHandler.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractFileUploadHandler.java index 98898b6a4e0..2f9a2618ff0 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractFileUploadHandler.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/AbstractFileUploadHandler.java @@ -101,20 +101,20 @@ public void handleUploadRequest(UploadEvent event) throws IOException { throw e; } } + // A validator may reject the upload during any phase (metadata, header + // or complete); all of them converge here. Rejection is fully settled + // by this point, since every validator has already run. The transfer's + // own onComplete may already have fired, so the rejection is surfaced + // as a terminal onError to give progress listeners a definitive signal, + // and the partially written file is discarded rather than delivered. if (event.isRejected()) { - // A validator rejected the upload; report it as a terminal error so - // progress listeners get a signal. notifyError(event, new UploadRejectedException(event.getRejectionMessage())); + deleteQuietly(file); + return; } final File uploadedFile = file; event.getUI().access(() -> { - // A validator may have rejected the upload while it was received; - // discard the written file and skip the callback. - if (event.isRejected()) { - deleteQuietly(uploadedFile); - return; - } try { successCallback.complete(metadata, uploadedFile); } catch (IOException e) { diff --git a/flow-server/src/main/java/com/vaadin/flow/server/streams/InMemoryUploadHandler.java b/flow-server/src/main/java/com/vaadin/flow/server/streams/InMemoryUploadHandler.java index 26e12aff477..2750e285a37 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/streams/InMemoryUploadHandler.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/streams/InMemoryUploadHandler.java @@ -41,18 +41,7 @@ public void handleUploadRequest(UploadEvent event) throws IOException { setTransferUI(event.getUI()); byte[] data = null; try { - runMetadataValidators(event); - if (!event.isRejected()) { - try (InputStream raw = event.getInputStream(); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { - InputStream in = applyHeaderValidators(event, raw); - if (!event.isRejected()) { - TransferUtil.transfer(in, outputStream, - getTransferContext(event), getListeners()); - data = outputStream.toByteArray(); - } - } - } + data = readContent(event); } catch (IOException e) { notifyError(event, e); throw e; @@ -70,19 +59,19 @@ public void handleUploadRequest(UploadEvent event) throws IOException { throw e; } } + // A validator may reject the upload during any phase (metadata, header + // or complete); all of them converge here. Rejection is fully settled + // by this point, since every validator has already run. The transfer's + // own onComplete may already have fired, so the rejection is surfaced + // as a terminal onError to give progress listeners a definitive signal, + // and the accumulated data is never delivered. if (event.isRejected()) { - // A validator rejected the upload; report it as a terminal error so - // progress listeners get a signal. notifyError(event, new UploadRejectedException(event.getRejectionMessage())); + return; } final byte[] delivered = data; event.getUI().access(() -> { - // A validator may have rejected the upload while it was received; - // the accumulated data must not be delivered in that case. - if (event.isRejected()) { - return; - } try { successCallback.complete( new UploadMetadata(event.getFileName(), @@ -94,4 +83,32 @@ public void handleUploadRequest(UploadEvent event) throws IOException { } }); } + + /** + * Runs metadata and header validation and, if the upload survives both, + * reads the full content into memory. + * + * @param event + * the upload being handled + * @return the received bytes, or {@code null} if a validator rejected the + * upload before the transfer completed + * @throws IOException + * if reading the content or a validator fails + */ + private byte[] readContent(UploadEvent event) throws IOException { + runMetadataValidators(event); + if (event.isRejected()) { + return null; + } + try (InputStream raw = event.getInputStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + InputStream in = applyHeaderValidators(event, raw); + if (event.isRejected()) { + return null; + } + TransferUtil.transfer(in, outputStream, getTransferContext(event), + getListeners()); + return outputStream.toByteArray(); + } + } }