diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-05-08 13:26:48 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-05-08 13:26:48 +0200 |
commit | 811ea36e148d25121ba4c2e5b1e80537454fd87f (patch) | |
tree | 6392b58b5c91f0a006623acdff1fa3fb753f5a94 | |
parent | 1c2b4f2a41951cfc476eed5a86e7c221b85af292 (diff) | |
download | nextcloud-server-feat/ocp-sanitize-filenames.tar.gz nextcloud-server-feat/ocp-sanitize-filenames.zip |
feat(FilenameValidator): allow to sanitize filenamesfeat/ocp-sanitize-filenames
Share the filename sanitizing with the OCP filename validator.
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
-rw-r--r-- | apps/files/lib/Command/SanitizeFilenames.php | 50 | ||||
-rw-r--r-- | lib/private/Files/FilenameValidator.php | 37 | ||||
-rw-r--r-- | lib/public/Files/IFilenameValidator.php | 13 | ||||
-rw-r--r-- | tests/lib/Files/FilenameValidatorTest.php | 134 |
4 files changed, 200 insertions, 34 deletions
diff --git a/apps/files/lib/Command/SanitizeFilenames.php b/apps/files/lib/Command/SanitizeFilenames.php index ea01afd20d6..a404f0b3fd9 100644 --- a/apps/files/lib/Command/SanitizeFilenames.php +++ b/apps/files/lib/Command/SanitizeFilenames.php @@ -27,7 +27,7 @@ use Symfony\Component\Console\Output\OutputInterface; class SanitizeFilenames extends Base { private OutputInterface $output; - private string $charReplacement; + private ?string $charReplacement; private bool $dryRun; public function __construct( @@ -43,10 +43,6 @@ class SanitizeFilenames extends Base { protected function configure(): void { parent::configure(); - $forbiddenCharacter = $this->filenameValidator->getForbiddenCharacters(); - $charReplacement = array_diff([' ', '_', '-'], $forbiddenCharacter); - $charReplacement = reset($charReplacement) ?: ''; - $this ->setName('files:sanitize-filenames') ->setDescription('Renames files to match naming constraints') @@ -65,16 +61,25 @@ class SanitizeFilenames extends Base { '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('<error>No character replacement given</error>'); - return 1; + // check if replacement is needed + $c = $this->filenameValidator->getForbiddenCharacters(); + if (count($c) > 0) { + try { + $this->filenameValidator->sanitizeFilename($c[0], $this->charReplacement); + } catch (\InvalidArgumentException) { + if ($this->charReplacement === null) { + $output->writeln('<error>Character replacement required</error>'); + } else { + $output->writeln('<error>Invalid character replacement given</error>'); + } + return 1; + } } $this->dryRun = $input->getOption('dry-run'); @@ -115,8 +120,8 @@ class SanitizeFilenames extends Base { try { $oldName = $node->getName(); - if (!$this->filenameValidator->isFilenameValid($oldName)) { - $newName = $this->sanitizeName($oldName); + $newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement); + if ($oldName !== $newName) { $newName = $folder->getNonExistingName($newName); $path = rtrim(dirname($node->getPath()), '/'); @@ -142,27 +147,4 @@ class SanitizeFilenames extends Base { } } - 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/lib/private/Files/FilenameValidator.php b/lib/private/Files/FilenameValidator.php index b1979789ec8..a77727aa947 100644 --- a/lib/private/Files/FilenameValidator.php +++ b/lib/private/Files/FilenameValidator.php @@ -228,6 +228,43 @@ class FilenameValidator implements IFilenameValidator { return false; } + public function sanitizeFilename(string $name, ?string $charReplacement = null): string { + $forbiddenCharacter = $this->getForbiddenCharacters(); + + if ($charReplacement === null) { + $charReplacement = array_diff([' ', '_', '-'], $forbiddenCharacter); + $charReplacement = reset($charReplacement) ?: ''; + } + if (mb_strlen($charReplacement) !== 1) { + throw new \InvalidArgumentException('No or invalid character replacement given'); + } + + $nameLowercase = mb_strtolower($name); + foreach ($this->getForbiddenExtensions() as $extension) { + if (str_ends_with($nameLowercase, $extension)) { + $name = substr($name, 0, strlen($name) - strlen($extension)); + } + } + + $basename = strlen($name) > 1 + ? substr($name, 0, strpos($name, '.', 1) ?: null) + : $name; + if (in_array(mb_strtolower($basename), $this->getForbiddenBasenames())) { + $name = str_replace($basename, $this->l10n->t('%1$s (renamed)', [$basename]), $name); + } + + if ($name === '') { + $name = $this->l10n->t('renamed file'); + } + + if (in_array(mb_strtolower($name), $this->getForbiddenFilenames())) { + $name = $this->l10n->t('%1$s (renamed)', [$name]); + } + + $name = str_replace($forbiddenCharacter, $charReplacement, $name); + return $name; + } + protected function checkForbiddenName(string $filename): void { $filename = mb_strtolower($filename); if ($this->isForbidden($filename)) { diff --git a/lib/public/Files/IFilenameValidator.php b/lib/public/Files/IFilenameValidator.php index 2bd3bb945dc..d8bd06d179d 100644 --- a/lib/public/Files/IFilenameValidator.php +++ b/lib/public/Files/IFilenameValidator.php @@ -36,4 +36,17 @@ interface IFilenameValidator { * @since 30.0.0 */ public function validateFilename(string $filename): void; + + /** + * Sanitize a give filename to comply with admin setup naming constrains. + * + * If no sanitizing is needed the same name is returned. + * + * @param string $name The filename to sanitize + * @param null|string $charReplacement Character to use for replacing forbidden ones - by default space, dash or underscore is used if allowed. + * @throws \InvalidArgumentException if no character replacement was given (and the default could not be applied) or the replacement is not valid. + * @since 32.0.0 + */ + public function sanitizeFilename(string $name, ?string $charReplacement = null): string; + } diff --git a/tests/lib/Files/FilenameValidatorTest.php b/tests/lib/Files/FilenameValidatorTest.php index 45f681bd453..c5361e2c648 100644 --- a/tests/lib/Files/FilenameValidatorTest.php +++ b/tests/lib/Files/FilenameValidatorTest.php @@ -374,4 +374,138 @@ class FilenameValidatorTest extends TestCase { [['AuX', 'COM1'], ['aux', 'com1']], ]; } + + /** + * @dataProvider dataSanitizeFilename + */ + public function testSanitizeFilename( + string $filename, + array $forbiddenNames, + array $forbiddenBasenames, + array $forbiddenExtensions, + array $forbiddenCharacters, + string $expected, + ): void { + /** @var FilenameValidator&MockObject */ + $validator = $this->getMockBuilder(FilenameValidator::class) + ->onlyMethods([ + 'getForbiddenBasenames', + 'getForbiddenExtensions', + 'getForbiddenFilenames', + 'getForbiddenCharacters', + ]) + ->setConstructorArgs([$this->l10n, $this->database, $this->config, $this->logger]) + ->getMock(); + + $validator->method('getForbiddenBasenames') + ->willReturn($forbiddenBasenames); + $validator->method('getForbiddenCharacters') + ->willReturn($forbiddenCharacters); + $validator->method('getForbiddenExtensions') + ->willReturn($forbiddenExtensions); + $validator->method('getForbiddenFilenames') + ->willReturn($forbiddenNames); + + $this->assertEquals($expected, $validator->sanitizeFilename($filename)); + } + + public function dataSanitizeFilename(): array { + return [ + 'valid name' => [ + 'a * b.txt', ['.htaccess'], [], [], [], 'a * b.txt' + ], + 'forbidden name in the middle is ok' => [ + 'a.htaccess.txt', ['.htaccess'], [], [], [], 'a.htaccess.txt' + ], + 'forbidden name on the beginning' => [ + '.htaccess.sample', ['.htaccess'], [], [], [], '.htaccess.sample' + ], + 'forbidden name' => [ + '.htaccess', ['.htaccess'], [], [], [], '.htaccess (renamed)' + ], + 'forbidden name - name is case insensitive' => [ + 'COM1', ['.htaccess', 'com1'], [], [], [], 'COM1 (renamed)' + ], + 'forbidden basename' => [ + 'com1.suffix', ['.htaccess'], ['com1'], [], [], 'com1 (renamed).suffix' + ], + 'forbidden basename case insensitive' => [ + // needed for Windows namespaces + 'COM1.suffix', ['.htaccess'], ['com1'], [], [], 'COM1 (renamed).suffix' + ], + 'forbidden basename for hidden files' => [ + // needed for Windows namespaces + '.thumbs.db', ['.htaccess'], ['.thumbs'], [], [], '.thumbs (renamed).db' + ], + 'invalid character' => [ + 'a: b.txt', ['.htaccess'], [], [], [':'], 'a b.txt', + ], + 'invalid extension' => [ + 'a: b.txt', ['.htaccess'], [], ['.txt'], [], 'a: b' + ], + 'invalid extension case insensitive' => [ + 'a: b.TXT', ['.htaccess'], [], ['.txt'], [], 'a: b' + ], + 'empty filename' => [ + '', [], [], [], [], 'renamed file' + ], + ]; + } + + /** + * @dataProvider dataSanitizeFilenameCharacterReplacement + */ + public function testSanitizeFilenameCharacterReplacement( + string $filename, + array $forbiddenCharacters, + ?string $characterReplacement, + ?string $expected, + ): void { + /** @var FilenameValidator&MockObject */ + $validator = $this->getMockBuilder(FilenameValidator::class) + ->onlyMethods([ + 'getForbiddenBasenames', + 'getForbiddenExtensions', + 'getForbiddenFilenames', + 'getForbiddenCharacters', + ]) + ->setConstructorArgs([$this->l10n, $this->database, $this->config, $this->logger]) + ->getMock(); + + $validator->method('getForbiddenBasenames') + ->willReturn([]); + $validator->method('getForbiddenCharacters') + ->willReturn($forbiddenCharacters); + $validator->method('getForbiddenExtensions') + ->willReturn([]); + $validator->method('getForbiddenFilenames') + ->willReturn([]); + + if ($expected === null) { + $this->expectException(\InvalidArgumentException::class); + $validator->sanitizeFilename($filename, $characterReplacement); + } else { + $this->assertEquals($expected, $validator->sanitizeFilename($filename, $characterReplacement)); + } + } + + public static function dataSanitizeFilenameCharacterReplacement(): array { + return [ + 'default' => [ + 'foo*bar', ['*'], null, 'foo bar' + ], + 'default - space not allowed' => [ + 'foo*bar', ['*', ' '], null, 'foo_bar' + ], + 'default - space and underscore not allowed' => [ + 'foo*bar', ['*', ' ', '_'], null, 'foo-bar' + ], + 'default - no replacement' => [ + 'foo*bar', ['*', ' ', '_', '-'], null, null + ], + 'custom replacement' => [ + 'foo*bar', ['*'], 'x', 'fooxbar' + ], + ]; + } } |