Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 16 additions & 34 deletions apps/files/lib/Command/SanitizeFilenames.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
class SanitizeFilenames extends Base {

private OutputInterface $output;
private string $charReplacement;
private ?string $charReplacement;
private bool $dryRun;

public function __construct(
Expand All @@ -43,10 +43,6 @@ public function __construct(
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')
Expand All @@ -65,16 +61,25 @@ protected function configure(): void {
'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');
Expand Down Expand Up @@ -115,8 +120,8 @@ private function sanitizeFiles(Folder $folder): void {

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()), '/');

Expand All @@ -142,27 +147,4 @@ private function sanitizeFiles(Folder $folder): void {
}
}

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;
}
}
37 changes: 37 additions & 0 deletions lib/private/Files/FilenameValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,43 @@ public function isForbidden(string $path): bool {
return false;
}

public function sanitizeFilename(string $name, ?string $charReplacement = null): string {
$forbiddenCharacters = $this->getForbiddenCharacters();

if ($charReplacement === null) {
$charReplacement = array_diff([' ', '_', '-'], $forbiddenCharacters);
$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($forbiddenCharacters, $charReplacement, $name);
return $name;
}

protected function checkForbiddenName(string $filename): void {
$filename = mb_strtolower($filename);
if ($this->isForbidden($filename)) {
Expand Down
13 changes: 13 additions & 0 deletions lib/public/Files/IFilenameValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,17 @@ public function isFilenameValid(string $filename): bool;
* @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;

}
134 changes: 134 additions & 0 deletions tests/lib/Files/FilenameValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -374,4 +374,138 @@ public static function dataGetForbiddenBasenames(): array {
[['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'
],
];
}
}
Loading