From bfe527484cb231af34c79549bdeb9e4a5cddbe57 Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Fri, 15 Oct 2021 09:01:34 +0100
Subject: [PATCH] Issue #3232248 by kim.pepper, dww, andypost, paulocs,
 alexpott, mstrelan, Berdir: Move _file_save_upload_single to a service and
 deprecate

---
 core/modules/file/file.module                 | 101 +++++-
 core/modules/file/file.services.yml           |   3 +
 .../file/src/Upload/FileUploadHandler.php     | 336 ++++++++++++++++++
 .../file/src/Upload/FileUploadResult.php      | 131 +++++++
 .../src/Upload/FileValidationException.php    |  60 ++++
 .../src/Kernel/FileUploadHandlerTest.php      |  51 +++
 ...{FileModuleTest.php => LegacyFileTest.php} |   4 +-
 7 files changed, 678 insertions(+), 8 deletions(-)
 create mode 100644 core/modules/file/src/Upload/FileUploadHandler.php
 create mode 100644 core/modules/file/src/Upload/FileUploadResult.php
 create mode 100644 core/modules/file/src/Upload/FileValidationException.php
 create mode 100644 core/modules/file/tests/src/Kernel/FileUploadHandlerTest.php
 rename core/modules/file/tests/src/Kernel/{FileModuleTest.php => LegacyFileTest.php} (84%)

diff --git a/core/modules/file/file.module b/core/modules/file/file.module
index e702f66c84bc..f17b773d8ca9 100644
--- a/core/modules/file/file.module
+++ b/core/modules/file/file.module
@@ -6,23 +6,32 @@
  */
 
 use Drupal\Component\Utility\Environment;
+use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Datetime\Entity\DateFormat;
+use Drupal\Core\Entity\EntityStorageInterface;
 use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
 use Drupal\Core\File\Exception\FileException;
+use Drupal\Core\File\Exception\FileExistsException;
+use Drupal\Core\File\Exception\FileWriteException;
+use Drupal\Core\File\Exception\InvalidStreamWrapperException;
 use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Link;
 use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\Render\Element;
 use Drupal\Core\Routing\RouteMatchInterface;
-use Drupal\Core\Link;
+use Drupal\Core\Template\Attribute;
 use Drupal\Core\Url;
 use Drupal\file\Entity\File;
-use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
 use Drupal\file\FileInterface;
-use Drupal\Component\Utility\NestedArray;
-use Drupal\Core\Entity\EntityStorageInterface;
-use Drupal\Core\Template\Attribute;
+use Drupal\file\Upload\FileValidationException;
+use Symfony\Component\HttpFoundation\File\Exception\FileException as SymfonyFileException;
+use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
+use Symfony\Component\HttpFoundation\File\Exception\IniSizeFileException;
+use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
+use Symfony\Component\HttpFoundation\File\Exception\PartialFileException;
 use Symfony\Component\Mime\MimeTypeGuesserInterface;
 
 /**
@@ -892,9 +901,82 @@ function file_save_upload($form_field_name, $validators = [], $destination = FAL
     $uploaded_files = [$file_upload];
   }
 
+  if ($destination === FALSE || $destination === NULL) {
+    $destination = 'temporary://';
+  }
+
+  /** @var \Drupal\file\Upload\FileUploadHandler $file_upload_handler */
+  $file_upload_handler = \Drupal::service('file.upload_handler');
+  /** @var \Drupal\Core\Render\RendererInterface $renderer */
+  $renderer = \Drupal::service('renderer');
   $files = [];
-  foreach ($uploaded_files as $i => $file_info) {
-    $files[$i] = _file_save_upload_single($file_info, $form_field_name, $validators, $destination, $replace);
+  /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $uploaded_file */
+  foreach ($uploaded_files as $i => $uploaded_file) {
+    try {
+      $result = $file_upload_handler->handleFileUpload($uploaded_file, $validators, $destination, $replace);
+      $file = $result->getFile();
+      // If the filename has been modified, let the user know.
+      if ($result->isRenamed()) {
+        if ($result->isSecurityRename()) {
+          $message = t('For security reasons, your upload has been renamed to %filename.', ['%filename' => $file->getFilename()]);
+        }
+        else {
+          $message = t('Your upload has been renamed to %filename.', ['%filename' => $file->getFilename()]);
+        }
+        \Drupal::messenger()->addStatus($message);
+      }
+      $files[$i] = $file;
+    }
+    catch (FileExistsException $e) {
+      \Drupal::messenger()->addError(t('Destination file "%file" exists', ['%file' => $destination . $uploaded_file->getFilename()]));
+      $files[$i] = FALSE;
+    }
+    catch (InvalidStreamWrapperException $e) {
+      \Drupal::messenger()->addError(t('The file could not be uploaded because the destination "%destination" is invalid.', ['%destination' => $destination]));
+      $files[$i] = FALSE;
+    }
+    catch (IniSizeFileException | FormSizeFileException $e) {
+      \Drupal::messenger()->addError(t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', [
+          '%file' => $uploaded_file->getFilename(),
+          '%maxsize' => format_size(Environment::getUploadMaxSize()),
+        ]));
+      $files[$i] = FALSE;
+    }
+    catch (PartialFileException | NoFileException $e) {
+      \Drupal::messenger()->addError(t('The file %file could not be saved because the upload did not complete.', [
+          '%file' => $uploaded_file->getFilename(),
+        ]));
+      $files[$i] = FALSE;
+    }
+    catch (SymfonyFileException $e) {
+      \Drupal::messenger()->addError(t('The file %file could not be saved. An unknown error has occurred.', ['%file' => $uploaded_file->getFilename()]));
+      $files[$i] = FALSE;
+    }
+    catch (FileValidationException $e) {
+      $message = [
+        'error' => [
+          '#markup' => t('The specified file %name could not be uploaded.', ['%name' => $e->getFilename()]),
+        ],
+        'item_list' => [
+          '#theme' => 'item_list',
+          '#items' => $e->getErrors(),
+        ],
+      ];
+      // @todo Add support for render arrays in
+      // \Drupal\Core\Messenger\MessengerInterface::addMessage()?
+      // @see https://www.drupal.org/node/2505497.
+      \Drupal::messenger()->addError($renderer->renderPlain($message));
+      $files[$i] = FALSE;
+    }
+    catch (FileWriteException $e) {
+      \Drupal::messenger()->addError(t('File upload error. Could not move uploaded file.'));
+      \Drupal::logger('file')->notice('Upload error. Could not move uploaded file %file to destination %destination.', ['%file' => $uploaded_file->getClientOriginalName(), '%destination' => $destination . '/' . $uploaded_file->getClientOriginalName()]);
+      $files[$i] = FALSE;
+    }
+    catch (FileException $e) {
+      \Drupal::messenger()->addError(t('The file %filename could not be uploaded because the name is invalid.', ['%filename' => $uploaded_file->getClientOriginalName()]));
+      $files[$i] = FALSE;
+    }
   }
 
   // Add files to the cache.
@@ -928,9 +1010,14 @@ function file_save_upload($form_field_name, $validators = [], $destination = FAL
  *   This method should only be called from file_save_upload(). Use that method
  *   instead.
  *
+ * @deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use
+ *   \Drupal\file\Upload\FileUploadHandler::handleFileUpload() instead.
+ *
+ * @see https://www.drupal.org/node/3239547
  * @see file_save_upload()
  */
 function _file_save_upload_single(\SplFileInfo $file_info, $form_field_name, $validators = [], $destination = FALSE, $replace = FileSystemInterface::EXISTS_REPLACE) {
+  @trigger_error(__METHOD__ . '() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\file\Upload\FileUploadHandler::handleFileUpload() instead. See https://www.drupal.org/node/3239547', E_USER_DEPRECATED);
   $user = \Drupal::currentUser();
   // Remember the original filename so we can print a message if it changes.
   $original_file_name = $file_info->getClientOriginalName();
diff --git a/core/modules/file/file.services.yml b/core/modules/file/file.services.yml
index 037547cb1f5c..5558d383d3d4 100644
--- a/core/modules/file/file.services.yml
+++ b/core/modules/file/file.services.yml
@@ -4,3 +4,6 @@ services:
     arguments: ['@config.factory', '@database', 'file_usage']
     tags:
       - { name: backend_overridable }
+  file.upload_handler:
+    class: Drupal\file\Upload\FileUploadHandler
+    arguments: [ '@file_system', '@entity_type.manager', '@stream_wrapper_manager', '@event_dispatcher', '@file.mime_type.guesser', '@current_user', '@request_stack' ]
diff --git a/core/modules/file/src/Upload/FileUploadHandler.php b/core/modules/file/src/Upload/FileUploadHandler.php
new file mode 100644
index 000000000000..bcfc983002cf
--- /dev/null
+++ b/core/modules/file/src/Upload/FileUploadHandler.php
@@ -0,0 +1,336 @@
+<?php
+
+namespace Drupal\file\Upload;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
+use Drupal\Core\File\Exception\FileExistsException;
+use Drupal\Core\File\Exception\FileWriteException;
+use Drupal\Core\File\Exception\InvalidStreamWrapperException;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
+use Drupal\file\Entity\File;
+use Drupal\file\FileInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\HttpFoundation\File\Exception\CannotWriteFileException;
+use Symfony\Component\HttpFoundation\File\Exception\ExtensionFileException;
+use Symfony\Component\HttpFoundation\File\Exception\FileException;
+use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
+use Symfony\Component\HttpFoundation\File\Exception\IniSizeFileException;
+use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
+use Symfony\Component\HttpFoundation\File\Exception\NoTmpDirFileException;
+use Symfony\Component\HttpFoundation\File\Exception\PartialFileException;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\Mime\MimeTypeGuesserInterface;
+
+/**
+ * Handles validating and creating file entities from file uploads.
+ */
+class FileUploadHandler {
+
+  /**
+   * The default extensions if none are provided.
+   */
+  const DEFAULT_EXTENSIONS = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp';
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The stream wrapper manager.
+   *
+   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
+   */
+  protected $streamWrapperManager;
+
+  /**
+   * The event dispatcher.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   */
+  protected $eventDispatcher;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The MIME type guesser.
+   *
+   * @var \Symfony\Component\Mime\MimeTypeGuesserInterface
+   */
+  protected $mimeTypeGuesser;
+
+  /**
+   * The request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * Constructs a FileUploadHandler object.
+   *
+   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
+   *   The file system service.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   The entity type manager.
+   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $streamWrapperManager
+   *   The stream wrapper manager.
+   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher
+   *   The event dispatcher.
+   * @param \Symfony\Component\Mime\MimeTypeGuesserInterface $mimeTypeGuesser
+   *   The MIME type guesser.
+   * @param \Drupal\Core\Session\AccountInterface $currentUser
+   *   The current user.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
+   *   The request stack.
+   */
+  public function __construct(FileSystemInterface $fileSystem, EntityTypeManagerInterface $entityTypeManager, StreamWrapperManagerInterface $streamWrapperManager, EventDispatcherInterface $eventDispatcher, MimeTypeGuesserInterface $mimeTypeGuesser, AccountInterface $currentUser, RequestStack $requestStack) {
+    $this->fileSystem = $fileSystem;
+    $this->entityTypeManager = $entityTypeManager;
+    $this->streamWrapperManager = $streamWrapperManager;
+    $this->eventDispatcher = $eventDispatcher;
+    $this->mimeTypeGuesser = $mimeTypeGuesser;
+    $this->currentUser = $currentUser;
+    $this->requestStack = $requestStack;
+  }
+
+  /**
+   * Creates a file from an upload.
+   *
+   * @param \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile
+   *   The uploaded file object.
+   * @param array $validators
+   *   The validators to run against the uploaded file.
+   * @param string $destination
+   *   The destination directory.
+   * @param int $replace
+   *   Replace behavior when the destination file already exists:
+   *   - FileSystemInterface::EXISTS_REPLACE - Replace the existing file.
+   *   - FileSystemInterface::EXISTS_RENAME - Append _{incrementing number}
+   *     until the filename is unique.
+   *   - FileSystemInterface::EXISTS_ERROR - Throw an exception.
+   *
+   * @return \Drupal\file\Upload\FileUploadResult
+   *   The created file entity.
+   *
+   * @throws \Symfony\Component\HttpFoundation\File\Exception\FileException
+   *   Thrown when a file upload error occurred.
+   * @throws \Drupal\Core\File\Exception\FileWriteException
+   *   Thrown when there is an error moving the file.
+   * @throws \Drupal\Core\File\Exception\FileException
+   *   Thrown when a file system error occurs.
+   * @throws \Drupal\file\Upload\FileValidationException
+   *   Thrown when file validation fails.
+   */
+  public function handleFileUpload(UploadedFile $uploadedFile, array $validators = [], string $destination = 'temporary://', int $replace = FileSystemInterface::EXISTS_REPLACE): FileUploadResult {
+    $originalName = $uploadedFile->getClientOriginalName();
+
+    if (!$uploadedFile->isValid()) {
+      switch ($uploadedFile->getError()) {
+        case \UPLOAD_ERR_INI_SIZE:
+          throw new IniSizeFileException($uploadedFile->getErrorMessage());
+
+        case \UPLOAD_ERR_FORM_SIZE:
+          throw new FormSizeFileException($uploadedFile->getErrorMessage());
+
+        case \UPLOAD_ERR_PARTIAL:
+          throw new PartialFileException($uploadedFile->getErrorMessage());
+
+        case \UPLOAD_ERR_NO_FILE:
+          throw new NoFileException($uploadedFile->getErrorMessage());
+
+        case \UPLOAD_ERR_CANT_WRITE:
+          throw new CannotWriteFileException($uploadedFile->getErrorMessage());
+
+        case \UPLOAD_ERR_NO_TMP_DIR:
+          throw new NoTmpDirFileException($uploadedFile->getErrorMessage());
+
+        case \UPLOAD_ERR_EXTENSION:
+          throw new ExtensionFileException($uploadedFile->getErrorMessage());
+
+      }
+
+      throw new FileException($uploadedFile->getErrorMessage());
+    }
+
+    $extensions = $this->handleExtensionValidation($validators);
+
+    // Assert that the destination contains a valid stream.
+    $destinationScheme = $this->streamWrapperManager::getScheme($destination);
+    if (!$this->streamWrapperManager->isValidScheme($destinationScheme)) {
+      throw new InvalidStreamWrapperException(sprintf('The file could not be uploaded because the destination "%s" is invalid.', $destination));
+    }
+
+    // A file URI may already have a trailing slash or look like "public://".
+    if (substr($destination, -1) != '/') {
+      $destination .= '/';
+    }
+
+    // Call an event to sanitize the filename and to attempt to address security
+    // issues caused by common server setups.
+    $event = new FileUploadSanitizeNameEvent($originalName, $extensions);
+    $this->eventDispatcher->dispatch($event);
+    $filename = $event->getFilename();
+
+    $mimeType = $this->mimeTypeGuesser->guessMimeType($filename);
+    $destinationFilename = $this->fileSystem->getDestinationFilename($destination . $filename, $replace);
+    if ($destinationFilename === FALSE) {
+      throw new FileExistsException(sprintf('Destination file "%s" exists', $destinationFilename));
+    }
+
+    $file = File::create([
+      'uid' => $this->currentUser->id(),
+      'status' => 0,
+      'uri' => $uploadedFile->getRealPath(),
+    ]);
+
+    // This will be replaced later with a filename based on the destination.
+    $file->setFilename($filename);
+    $file->setMimeType($mimeType);
+    $file->setSize($uploadedFile->getSize());
+
+    // Add in our check of the file name length.
+    $validators['file_validate_name_length'] = [];
+
+    // Call the validation functions specified by this function's caller.
+    $errors = file_validate($file, $validators);
+    if (!empty($errors)) {
+      throw new FileValidationException('File validation failed', $filename, $errors);
+    }
+
+    $file->setFileUri($destinationFilename);
+    if (!$this->fileSystem->moveUploadedFile($uploadedFile->getRealPath(), $file->getFileUri())) {
+      throw new FileWriteException('File upload error. Could not move uploaded file.');
+    }
+
+    // Update the filename with any changes as a result of security or renaming
+    // due to an existing file.
+    $file->setFilename($this->fileSystem->basename($file->getFileUri()));
+
+    if ($replace === FileSystemInterface::EXISTS_REPLACE) {
+      $existingFile = $this->loadByUri($file->getFileUri());
+      if ($existingFile) {
+        $file->fid = $existingFile->id();
+        $file->setOriginalId($existingFile->id());
+      }
+    }
+
+    $result = (new FileUploadResult())
+      ->setOriginalFilename($originalName)
+      ->setSanitizedFilename($filename)
+      ->setFile($file);
+
+    // If the filename has been modified, let the user know.
+    if ($event->isSecurityRename()) {
+      $result->setSecurityRename();
+    }
+
+    // Set the permissions on the new file.
+    $this->fileSystem->chmod($file->getFileUri());
+
+    // We can now validate the file object itself before it's saved.
+    $violations = $file->validate();
+    foreach ($violations as $violation) {
+      $errors[] = $violation->getMessage();
+    }
+    if (!empty($errors)) {
+      throw new FileValidationException('File validation failed', $filename, $errors);
+    }
+
+    // If we made it this far it's safe to record this file in the database.
+    $file->save();
+
+    // Allow an anonymous user who creates a non-public file to see it. See
+    // \Drupal\file\FileAccessControlHandler::checkAccess().
+    if ($this->currentUser->isAnonymous() && $destinationScheme !== 'public') {
+      $session = $this->requestStack->getCurrentRequest()->getSession();
+      $allowed_temp_files = $session->get('anonymous_allowed_file_ids', []);
+      $allowed_temp_files[$file->id()] = $file->id();
+      $session->set('anonymous_allowed_file_ids', $allowed_temp_files);
+    }
+
+    return $result;
+  }
+
+  /**
+   * Gets the list of allowed extensions and updates the validators.
+   *
+   * This will add an extension validator to the list of validators if one is
+   * not set.
+   *
+   * If the extension validator is set, but no extensions are specified, it
+   * means all extensions are allowed, so the validator is removed from the list
+   * of validators.
+   *
+   * @param array $validators
+   *   The file validators in use.
+   *
+   * @return string
+   *   The space delimited list of allowed file extensions.
+   */
+  protected function handleExtensionValidation(array &$validators): string {
+    // Build a list of allowed extensions.
+    if (isset($validators['file_validate_extensions'])) {
+      if (!isset($validators['file_validate_extensions'][0])) {
+        // If 'file_validate_extensions' is set and the list is empty then the
+        // caller wants to allow any extension. In this case we have to remove the
+        // validator or else it will reject all extensions.
+        unset($validators['file_validate_extensions']);
+      }
+    }
+    else {
+      // No validator was provided, so add one using the default list.
+      // Build a default non-munged safe list for
+      // \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber::sanitizeName().
+      $validators['file_validate_extensions'] = [self::DEFAULT_EXTENSIONS];
+    }
+    return $validators['file_validate_extensions'][0] ?? '';
+  }
+
+  /**
+   * Loads the first File entity found with the specified URI.
+   *
+   * @param string $uri
+   *   The file URI.
+   *
+   * @return \Drupal\file\FileInterface|null
+   *   The first file with the matched URI if found, NULL otherwise.
+   *
+   * @todo replace with https://www.drupal.org/project/drupal/issues/3223209
+   */
+  protected function loadByUri(string $uri): ?FileInterface {
+    $fileStorage = $this->entityTypeManager->getStorage('file');
+    /** @var \Drupal\file\FileInterface[] $files */
+    $files = $fileStorage->loadByProperties(['uri' => $uri]);
+    if (count($files)) {
+      foreach ($files as $item) {
+        // Since some database servers sometimes use a case-insensitive
+        // comparison by default, double check that the filename is an exact
+        // match.
+        if ($item->getFileUri() === $uri) {
+          return $item;
+        }
+      }
+    }
+    return NULL;
+  }
+
+}
diff --git a/core/modules/file/src/Upload/FileUploadResult.php b/core/modules/file/src/Upload/FileUploadResult.php
new file mode 100644
index 000000000000..8481002cf602
--- /dev/null
+++ b/core/modules/file/src/Upload/FileUploadResult.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Drupal\file\Upload;
+
+use Drupal\file\FileInterface;
+
+/**
+ * Value object for a file upload result.
+ */
+class FileUploadResult {
+
+  /**
+   * If the filename was renamed for security reasons.
+   *
+   * @var bool
+   */
+  protected $securityRename = FALSE;
+
+  /**
+   * The sanitized filename.
+   *
+   * @var string
+   */
+  protected $sanitizedFilename;
+
+  /**
+   * The original filename.
+   *
+   * @var string
+   */
+  protected $originalFilename;
+
+  /**
+   * The File entity.
+   *
+   * @var \Drupal\file\FileInterface
+   */
+  protected $file;
+
+  /**
+   * Flags the result as having had a security rename.
+   *
+   * @return $this
+   */
+  public function setSecurityRename(): FileUploadResult {
+    $this->securityRename = TRUE;
+    return $this;
+  }
+
+  /**
+   * Sets the sanitized filename.
+   *
+   * @param string $sanitizedFilename
+   *
+   * @return $this
+   */
+  public function setSanitizedFilename(string $sanitizedFilename): FileUploadResult {
+    $this->sanitizedFilename = $sanitizedFilename;
+    return $this;
+  }
+
+  /**
+   * Gets the original filename.
+   *
+   * @return string
+   */
+  public function getOriginalFilename(): string {
+    return $this->originalFilename;
+  }
+
+  /**
+   * Sets the original filename.
+   *
+   * @param string $originalFilename
+   *
+   * @return $this
+   */
+  public function setOriginalFilename(string $originalFilename): FileUploadResult {
+    $this->originalFilename = $originalFilename;
+    return $this;
+  }
+
+  /**
+   * Sets the File entity.
+   *
+   * @param \Drupal\file\FileInterface $file
+   *
+   * @return $this
+   */
+  public function setFile(FileInterface $file): FileUploadResult {
+    $this->file = $file;
+    return $this;
+  }
+
+  /**
+   * Returns if there was a security rename.
+   *
+   * @return bool
+   */
+  public function isSecurityRename(): bool {
+    return $this->securityRename;
+  }
+
+  /**
+   * Returns if there was a file rename.
+   *
+   * @return bool
+   */
+  public function isRenamed(): bool {
+    return $this->originalFilename !== $this->sanitizedFilename;
+  }
+
+  /**
+   * Gets the sanitized filename.
+   *
+   * @return string
+   */
+  public function getSanitizedFilename(): string {
+    return $this->sanitizedFilename;
+  }
+
+  /**
+   * Gets the File entity.
+   *
+   * @return \Drupal\file\FileInterface
+   */
+  public function getFile(): FileInterface {
+    return $this->file;
+  }
+
+}
diff --git a/core/modules/file/src/Upload/FileValidationException.php b/core/modules/file/src/Upload/FileValidationException.php
new file mode 100644
index 000000000000..c5bc7b40dd52
--- /dev/null
+++ b/core/modules/file/src/Upload/FileValidationException.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\file\Upload;
+
+/**
+ * Provides an exception for upload validation errors.
+ */
+class FileValidationException extends \RuntimeException {
+
+  /**
+   * The validation errors.
+   *
+   * @var array
+   */
+  protected $errors;
+
+  /**
+   * The file name.
+   *
+   * @var string
+   */
+  protected $fileName;
+
+  /**
+   * Constructs a new FileValidationException.
+   *
+   * @param string $message
+   *   The message.
+   * @param string $file_name
+   *   The file name.
+   * @param array $errors
+   *   The validation errors.
+   */
+  public function __construct(string $message, string $file_name, array $errors) {
+    parent::__construct($message, 0, NULL);
+    $this->fileName = $file_name;
+    $this->errors = $errors;
+  }
+
+  /**
+   * Gets the file name.
+   *
+   * @return string
+   *   The file name.
+   */
+  public function getFilename(): string {
+    return $this->fileName;
+  }
+
+  /**
+   * Gets the errors.
+   *
+   * @return array
+   *   The errors.
+   */
+  public function getErrors(): array {
+    return $this->errors;
+  }
+
+}
diff --git a/core/modules/file/tests/src/Kernel/FileUploadHandlerTest.php b/core/modules/file/tests/src/Kernel/FileUploadHandlerTest.php
new file mode 100644
index 000000000000..5c8fb7fb0bf0
--- /dev/null
+++ b/core/modules/file/tests/src/Kernel/FileUploadHandlerTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\Tests\file\Kernel;
+
+use Drupal\Component\Utility\Environment;
+use Drupal\KernelTests\KernelTestBase;
+use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+/**
+ * Tests the file upload handler.
+ *
+ * @group file
+ */
+class FileUploadHandlerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['file'];
+
+  /**
+   * The file upload handler under test.
+   *
+   * @var \Drupal\file\Upload\FileUploadHandler
+   */
+  protected $fileUploadHandler;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->fileUploadHandler = $this->container->get('file.upload_handler');
+  }
+
+  /**
+   * Tests file size upload errors.
+   */
+  public function testFileSaveUploadSingleErrorFormSize() {
+    $file_name = $this->randomMachineName();
+    $file_info = $this->createMock(UploadedFile::class);
+    $file_info->expects($this->once())->method('getError')->willReturn(UPLOAD_ERR_FORM_SIZE);
+    $file_info->expects($this->once())->method('getClientOriginalName')->willReturn($file_name);
+    $file_info->expects($this->once())->method('getErrorMessage')->willReturn(sprintf('The file "%s" could not be saved because it exceeds %s, the maximum allowed size for uploads.', $file_name, format_size(Environment::getUploadMaxSize())));
+    $this->expectException(FormSizeFileException::class);
+    $this->expectExceptionMessage(sprintf('The file "%s" could not be saved because it exceeds %s, the maximum allowed size for uploads.', $file_name, format_size(Environment::getUploadMaxSize())));
+    $this->fileUploadHandler->handleFileUpload($file_info);
+  }
+
+}
diff --git a/core/modules/file/tests/src/Kernel/FileModuleTest.php b/core/modules/file/tests/src/Kernel/LegacyFileTest.php
similarity index 84%
rename from core/modules/file/tests/src/Kernel/FileModuleTest.php
rename to core/modules/file/tests/src/Kernel/LegacyFileTest.php
index beb925f97d75..a3a46257f03e 100644
--- a/core/modules/file/tests/src/Kernel/FileModuleTest.php
+++ b/core/modules/file/tests/src/Kernel/LegacyFileTest.php
@@ -11,8 +11,9 @@
  * Tests file.module methods.
  *
  * @group file
+ * @group legacy
  */
-class FileModuleTest extends KernelTestBase {
+class LegacyFileTest extends KernelTestBase {
 
   /**
    * {@inheritdoc}
@@ -29,6 +30,7 @@ public function testFileSaveUploadSingleErrorFormSize() {
     $file_info = $this->createMock(UploadedFile::class);
     $file_info->expects($this->once())->method('getError')->willReturn(UPLOAD_ERR_FORM_SIZE);
     $file_info->expects($this->once())->method('getClientOriginalName')->willReturn($file_name);
+    $this->expectDeprecation('_file_save_upload_single() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\file\Upload\FileUploadHandler::handleFileUpload() instead. See https://www.drupal.org/node/3239547');
     $this->assertFalse(\_file_save_upload_single($file_info, 'name'));
     $expected_message = new TranslatableMarkup('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', ['%file' => $file_name, '%maxsize' => format_size(Environment::getUploadMaxSize())]);
     $this->assertEquals($expected_message, \Drupal::messenger()->all()['error'][0]);
-- 
GitLab