diff --git a/apps/files/appinfo/info.xml b/apps/files/appinfo/info.xml
index 95f6153e1d7..267c2cff289 100644
--- a/apps/files/appinfo/info.xml
+++ b/apps/files/appinfo/info.xml
@@ -46,6 +46,7 @@
OCA\Files\Command\Delete
OCA\Files\Command\Copy
OCA\Files\Command\Move
+ OCA\Files\Command\SanitizeFilenames
OCA\Files\Command\Object\Delete
OCA\Files\Command\Object\Get
OCA\Files\Command\Object\Put
diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php
index a74df7ba3d2..39cf31ed4fe 100644
--- a/apps/files/composer/composer/autoload_classmap.php
+++ b/apps/files/composer/composer/autoload_classmap.php
@@ -42,6 +42,7 @@ return array(
'OCA\\Files\\Command\\Object\\Put' => $baseDir . '/../lib/Command/Object/Put.php',
'OCA\\Files\\Command\\Put' => $baseDir . '/../lib/Command/Put.php',
'OCA\\Files\\Command\\RepairTree' => $baseDir . '/../lib/Command/RepairTree.php',
+ 'OCA\\Files\\Command\\SanitizeFilenames' => $baseDir . '/../lib/Command/SanitizeFilenames.php',
'OCA\\Files\\Command\\Scan' => $baseDir . '/../lib/Command/Scan.php',
'OCA\\Files\\Command\\ScanAppData' => $baseDir . '/../lib/Command/ScanAppData.php',
'OCA\\Files\\Command\\TransferOwnership' => $baseDir . '/../lib/Command/TransferOwnership.php',
diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php
index 1d79f38e35a..6e61ca976d1 100644
--- a/apps/files/composer/composer/autoload_static.php
+++ b/apps/files/composer/composer/autoload_static.php
@@ -57,6 +57,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Command\\Object\\Put' => __DIR__ . '/..' . '/../lib/Command/Object/Put.php',
'OCA\\Files\\Command\\Put' => __DIR__ . '/..' . '/../lib/Command/Put.php',
'OCA\\Files\\Command\\RepairTree' => __DIR__ . '/..' . '/../lib/Command/RepairTree.php',
+ 'OCA\\Files\\Command\\SanitizeFilenames' => __DIR__ . '/..' . '/../lib/Command/SanitizeFilenames.php',
'OCA\\Files\\Command\\Scan' => __DIR__ . '/..' . '/../lib/Command/Scan.php',
'OCA\\Files\\Command\\ScanAppData' => __DIR__ . '/..' . '/../lib/Command/ScanAppData.php',
'OCA\\Files\\Command\\TransferOwnership' => __DIR__ . '/..' . '/../lib/Command/TransferOwnership.php',
diff --git a/apps/files/lib/Command/SanitizeFilenames.php b/apps/files/lib/Command/SanitizeFilenames.php
new file mode 100644
index 00000000000..ea01afd20d6
--- /dev/null
+++ b/apps/files/lib/Command/SanitizeFilenames.php
@@ -0,0 +1,168 @@
+filenameValidator->getForbiddenCharacters();
+ $charReplacement = array_diff([' ', '_', '-'], $forbiddenCharacter);
+ $charReplacement = reset($charReplacement) ?: '';
+
+ $this
+ ->setName('files:sanitize-filenames')
+ ->setDescription('Renames files to match naming constraints')
+ ->addArgument(
+ 'user_id',
+ InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
+ 'will only rename files the given user(s) have access to'
+ )
+ ->addOption(
+ 'dry-run',
+ mode: InputOption::VALUE_NONE,
+ description: 'Do not actually rename any files but just check filenames.',
+ )
+ ->addOption(
+ 'char-replacement',
+ 'c',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Replacement for invalid character (by default space, underscore or dash is used)',
+ default: $charReplacement,
+ );
+
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $this->charReplacement = $input->getOption('char-replacement');
+ if ($this->charReplacement === '' || mb_strlen($this->charReplacement) > 1) {
+ $output->writeln('No character replacement given');
+ return 1;
+ }
+
+ $this->dryRun = $input->getOption('dry-run');
+ if ($this->dryRun) {
+ $output->writeln('Dry run is enabled, no actual renaming will be applied.>');
+ }
+
+ $this->output = $output;
+ $users = $input->getArgument('user_id');
+ if (!empty($users)) {
+ foreach ($users as $userId) {
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ $output->writeln("User '$userId' does not exist - skipping>");
+ continue;
+ }
+ $this->sanitizeUserFiles($user);
+ }
+ } else {
+ $this->userManager->callForSeenUsers($this->sanitizeUserFiles(...));
+ }
+ return self::SUCCESS;
+ }
+
+ private function sanitizeUserFiles(IUser $user): void {
+ // Set an active user so that event listeners can correctly work (e.g. files versions)
+ $this->session->setVolatileActiveUser($user);
+
+ $this->output->writeln('Analyzing files of ' . $user->getUID() . '>');
+
+ $folder = $this->rootFolder->getUserFolder($user->getUID());
+ $this->sanitizeFiles($folder);
+ }
+
+ private function sanitizeFiles(Folder $folder): void {
+ foreach ($folder->getDirectoryListing() as $node) {
+ $this->output->writeln('scanning: ' . $node->getPath(), OutputInterface::VERBOSITY_VERBOSE);
+
+ try {
+ $oldName = $node->getName();
+ if (!$this->filenameValidator->isFilenameValid($oldName)) {
+ $newName = $this->sanitizeName($oldName);
+ $newName = $folder->getNonExistingName($newName);
+ $path = rtrim(dirname($node->getPath()), '/');
+
+ if (!$this->dryRun) {
+ $node->move("$path/$newName");
+ } elseif (!$folder->isCreatable()) {
+ // simulate error for dry run
+ throw new NotPermittedException();
+ }
+ $this->output->writeln('renamed: "' . $oldName . '" to "' . $newName . '"');
+ }
+ } catch (LockedException) {
+ $this->output->writeln('skipping: ' . $node->getPath() . ' (file is locked)>');
+ } catch (NotPermittedException) {
+ $this->output->writeln('skipping: ' . $node->getPath() . ' (no permissions)>');
+ } catch (Exception) {
+ $this->output->writeln('failed: ' . $node->getPath() . '>');
+ }
+
+ if ($node instanceof Folder) {
+ $this->sanitizeFiles($node);
+ }
+ }
+ }
+
+ private function sanitizeName(string $name): string {
+ $l10n = $this->l10nFactory->get('files');
+
+ foreach ($this->filenameValidator->getForbiddenExtensions() as $extension) {
+ if (str_ends_with($name, $extension)) {
+ $name = substr($name, 0, strlen($name) - strlen($extension));
+ }
+ }
+
+ $basename = substr($name, 0, strpos($name, '.', 1) ?: null);
+ if (in_array($basename, $this->filenameValidator->getForbiddenBasenames())) {
+ $name = str_replace($basename, $l10n->t('%1$s (renamed)', [$basename]), $name);
+ }
+
+ if ($name === '') {
+ $name = $l10n->t('renamed file');
+ }
+
+ $forbiddenCharacter = $this->filenameValidator->getForbiddenCharacters();
+ $name = str_replace($forbiddenCharacter, $this->charReplacement, $name);
+
+ return $name;
+ }
+}
diff --git a/build/integration/files_features/windows_compatibility.feature b/build/integration/files_features/windows_compatibility.feature
new file mode 100644
index 00000000000..2fdd37d67a1
--- /dev/null
+++ b/build/integration/files_features/windows_compatibility.feature
@@ -0,0 +1,68 @@
+# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+Feature: Windows compatible filenames
+ Background:
+ Given using api version "1"
+ And using new dav path
+ And As an "admin"
+
+ Scenario: prevent upload files with invalid name
+ Given As an "admin"
+ And user "user0" exists
+ And invoking occ with "files:windows-compatible-filenames --enable"
+ Given User "user0" created a folder "/com1"
+ Then as "user0" the file "/com1" does not exist
+
+ Scenario: renaming a folder with invalid name
+ Given As an "admin"
+ When invoking occ with "files:windows-compatible-filenames --disable"
+ And user "user0" exists
+ Given User "user0" created a folder "/aux"
+ When invoking occ with "files:windows-compatible-filenames --enable"
+ And invoking occ with "files:sanitize-filenames user0"
+ Then as "user0" the file "/aux" does not exist
+ And as "user0" the file "/aux (renamed)" exists
+
+ Scenario: renaming a file with invalid base name
+ Given As an "admin"
+ When invoking occ with "files:windows-compatible-filenames --disable"
+ And user "user0" exists
+ When User "user0" uploads file with content "hello" to "/com0.txt"
+ And invoking occ with "files:windows-compatible-filenames --enable"
+ And invoking occ with "files:sanitize-filenames user0"
+ Then as "user0" the file "/com0.txt" does not exist
+ And as "user0" the file "/com0 (renamed).txt" exists
+
+ Scenario: renaming a file with invalid extension
+ Given As an "admin"
+ When invoking occ with "files:windows-compatible-filenames --disable"
+ And user "user0" exists
+ When User "user0" uploads file with content "hello" to "/foo.txt."
+ And as "user0" the file "/foo.txt." exists
+ And invoking occ with "files:windows-compatible-filenames --enable"
+ And invoking occ with "files:sanitize-filenames user0"
+ Then as "user0" the file "/foo.txt." does not exist
+ And as "user0" the file "/foo.txt" exists
+
+ Scenario: renaming a file with invalid character
+ Given As an "admin"
+ When invoking occ with "files:windows-compatible-filenames --disable"
+ And user "user0" exists
+ When User "user0" uploads file with content "hello" to "/2*2=4.txt"
+ And as "user0" the file "/2*2=4.txt" exists
+ And invoking occ with "files:windows-compatible-filenames --enable"
+ And invoking occ with "files:sanitize-filenames user0"
+ Then as "user0" the file "/2*2=4.txt" does not exist
+ And as "user0" the file "/2 2=4.txt" exists
+
+ Scenario: renaming a file with invalid character and replacement setup
+ Given As an "admin"
+ When invoking occ with "files:windows-compatible-filenames --disable"
+ And user "user0" exists
+ When User "user0" uploads file with content "hello" to "/2*3=6.txt"
+ And as "user0" the file "/2*3=6.txt" exists
+ And invoking occ with "files:windows-compatible-filenames --enable"
+ And invoking occ with "files:sanitize-filenames --char-replacement + user0"
+ Then as "user0" the file "/2*3=6.txt" does not exist
+ And as "user0" the file "/2+3=6.txt" exists