Completed
Push — master ( 8c68f1...fb615e )
by
unknown
28:32 queued 14s
created
lib/public/Files/IFilenameValidator.php 1 patch
Indentation   +33 added lines, -33 removed lines patch added patch discarded remove patch
@@ -13,40 +13,40 @@
 block discarded – undo
13 13
  */
14 14
 interface IFilenameValidator {
15 15
 
16
-	/**
17
-	 * It is recommended to use `\OCP\Files\Storage\IStorage::isFileValid` instead as this
18
-	 * only checks if the filename is valid in general but not for a specific storage
19
-	 * which might have additional naming rules.
20
-	 *
21
-	 * @param string $filename The filename to check for validity
22
-	 * @return bool
23
-	 * @since 30.0.0
24
-	 */
25
-	public function isFilenameValid(string $filename): bool;
16
+    /**
17
+     * It is recommended to use `\OCP\Files\Storage\IStorage::isFileValid` instead as this
18
+     * only checks if the filename is valid in general but not for a specific storage
19
+     * which might have additional naming rules.
20
+     *
21
+     * @param string $filename The filename to check for validity
22
+     * @return bool
23
+     * @since 30.0.0
24
+     */
25
+    public function isFilenameValid(string $filename): bool;
26 26
 
27
-	/**
28
-	 * It is recommended to use `\OCP\Files\Storage\IStorage::isFileValid` instead as this
29
-	 * only checks if the filename is valid in general but not for a specific storage
30
-	 * which might have additional naming rules.
31
-	 *
32
-	 * This will validate a filename and throw an exception with details on error.
33
-	 *
34
-	 * @param string $filename The filename to check for validity
35
-	 * @throws \OCP\Files\InvalidPathException or one of its child classes in case of an error
36
-	 * @since 30.0.0
37
-	 */
38
-	public function validateFilename(string $filename): void;
27
+    /**
28
+     * It is recommended to use `\OCP\Files\Storage\IStorage::isFileValid` instead as this
29
+     * only checks if the filename is valid in general but not for a specific storage
30
+     * which might have additional naming rules.
31
+     *
32
+     * This will validate a filename and throw an exception with details on error.
33
+     *
34
+     * @param string $filename The filename to check for validity
35
+     * @throws \OCP\Files\InvalidPathException or one of its child classes in case of an error
36
+     * @since 30.0.0
37
+     */
38
+    public function validateFilename(string $filename): void;
39 39
 
40
-	/**
41
-	 * Sanitize a give filename to comply with admin setup naming constrains.
42
-	 *
43
-	 * If no sanitizing is needed the same name is returned.
44
-	 *
45
-	 * @param string $name The filename to sanitize
46
-	 * @param null|string $charReplacement Character to use for replacing forbidden ones - by default space, dash or underscore is used if allowed.
47
-	 * @throws \InvalidArgumentException if no character replacement was given (and the default could not be applied) or the replacement is not valid.
48
-	 * @since 32.0.0
49
-	 */
50
-	public function sanitizeFilename(string $name, ?string $charReplacement = null): string;
40
+    /**
41
+     * Sanitize a give filename to comply with admin setup naming constrains.
42
+     *
43
+     * If no sanitizing is needed the same name is returned.
44
+     *
45
+     * @param string $name The filename to sanitize
46
+     * @param null|string $charReplacement Character to use for replacing forbidden ones - by default space, dash or underscore is used if allowed.
47
+     * @throws \InvalidArgumentException if no character replacement was given (and the default could not be applied) or the replacement is not valid.
48
+     * @since 32.0.0
49
+     */
50
+    public function sanitizeFilename(string $name, ?string $charReplacement = null): string;
51 51
 
52 52
 }
Please login to merge, or discard this patch.
lib/private/Files/FilenameValidator.php 1 patch
Indentation   +303 added lines, -303 removed lines patch added patch discarded remove patch
@@ -25,311 +25,311 @@
 block discarded – undo
25 25
  */
26 26
 class FilenameValidator implements IFilenameValidator {
27 27
 
28
-	public const INVALID_FILE_TYPE = 100;
29
-
30
-	private IL10N $l10n;
31
-
32
-	/**
33
-	 * @var list<string>
34
-	 */
35
-	private array $forbiddenNames = [];
36
-
37
-	/**
38
-	 * @var list<string>
39
-	 */
40
-	private array $forbiddenBasenames = [];
41
-	/**
42
-	 * @var list<string>
43
-	 */
44
-	private array $forbiddenCharacters = [];
45
-
46
-	/**
47
-	 * @var list<string>
48
-	 */
49
-	private array $forbiddenExtensions = [];
50
-
51
-	public function __construct(
52
-		IFactory $l10nFactory,
53
-		private IDBConnection $database,
54
-		private IConfig $config,
55
-		private LoggerInterface $logger,
56
-	) {
57
-		$this->l10n = $l10nFactory->get('core');
58
-	}
59
-
60
-	/**
61
-	 * Get a list of reserved filenames that must not be used
62
-	 * This list should be checked case-insensitive, all names are returned lowercase.
63
-	 * @return list<string>
64
-	 * @since 30.0.0
65
-	 */
66
-	public function getForbiddenExtensions(): array {
67
-		if (empty($this->forbiddenExtensions)) {
68
-			$forbiddenExtensions = $this->getConfigValue('forbidden_filename_extensions', ['.filepart']);
69
-
70
-			// Always forbid .part files as they are used internally
71
-			$forbiddenExtensions[] = '.part';
72
-
73
-			$this->forbiddenExtensions = array_values($forbiddenExtensions);
74
-		}
75
-		return $this->forbiddenExtensions;
76
-	}
77
-
78
-	/**
79
-	 * Get a list of forbidden filename extensions that must not be used
80
-	 * This list should be checked case-insensitive, all names are returned lowercase.
81
-	 * @return list<string>
82
-	 * @since 30.0.0
83
-	 */
84
-	public function getForbiddenFilenames(): array {
85
-		if (empty($this->forbiddenNames)) {
86
-			$forbiddenNames = $this->getConfigValue('forbidden_filenames', ['.htaccess']);
87
-
88
-			// Handle legacy config option
89
-			// TODO: Drop with Nextcloud 34
90
-			$legacyForbiddenNames = $this->getConfigValue('blacklisted_files', []);
91
-			if (!empty($legacyForbiddenNames)) {
92
-				$this->logger->warning('System config option "blacklisted_files" is deprecated and will be removed in Nextcloud 34, use "forbidden_filenames" instead.');
93
-			}
94
-			$forbiddenNames = array_merge($legacyForbiddenNames, $forbiddenNames);
95
-
96
-			// Ensure we are having a proper string list
97
-			$this->forbiddenNames = array_values($forbiddenNames);
98
-		}
99
-		return $this->forbiddenNames;
100
-	}
101
-
102
-	/**
103
-	 * Get a list of forbidden file basenames that must not be used
104
-	 * This list should be checked case-insensitive, all names are returned lowercase.
105
-	 * @return list<string>
106
-	 * @since 30.0.0
107
-	 */
108
-	public function getForbiddenBasenames(): array {
109
-		if (empty($this->forbiddenBasenames)) {
110
-			$forbiddenBasenames = $this->getConfigValue('forbidden_filename_basenames', []);
111
-			// Ensure we are having a proper string list
112
-			$this->forbiddenBasenames = array_values($forbiddenBasenames);
113
-		}
114
-		return $this->forbiddenBasenames;
115
-	}
116
-
117
-	/**
118
-	 * Get a list of characters forbidden in filenames
119
-	 *
120
-	 * Note: Characters in the range [0-31] are always forbidden,
121
-	 * even if not inside this list (see OCP\Files\Storage\IStorage::verifyPath).
122
-	 *
123
-	 * @return list<string>
124
-	 * @since 30.0.0
125
-	 */
126
-	public function getForbiddenCharacters(): array {
127
-		if (empty($this->forbiddenCharacters)) {
128
-			// Get always forbidden characters
129
-			$forbiddenCharacters = str_split(\OCP\Constants::FILENAME_INVALID_CHARS);
130
-
131
-			// Get admin defined invalid characters
132
-			$additionalChars = $this->config->getSystemValue('forbidden_filename_characters', []);
133
-			if (!is_array($additionalChars)) {
134
-				$this->logger->error('Invalid system config value for "forbidden_filename_characters" is ignored.');
135
-				$additionalChars = [];
136
-			}
137
-			$forbiddenCharacters = array_merge($forbiddenCharacters, $additionalChars);
138
-
139
-			// Handle legacy config option
140
-			// TODO: Drop with Nextcloud 34
141
-			$legacyForbiddenCharacters = $this->config->getSystemValue('forbidden_chars', []);
142
-			if (!is_array($legacyForbiddenCharacters)) {
143
-				$this->logger->error('Invalid system config value for "forbidden_chars" is ignored.');
144
-				$legacyForbiddenCharacters = [];
145
-			}
146
-			if (!empty($legacyForbiddenCharacters)) {
147
-				$this->logger->warning('System config option "forbidden_chars" is deprecated and will be removed in Nextcloud 34, use "forbidden_filename_characters" instead.');
148
-			}
149
-			$forbiddenCharacters = array_merge($legacyForbiddenCharacters, $forbiddenCharacters);
150
-
151
-			$this->forbiddenCharacters = array_values($forbiddenCharacters);
152
-		}
153
-		return $this->forbiddenCharacters;
154
-	}
155
-
156
-	/**
157
-	 * @inheritdoc
158
-	 */
159
-	public function isFilenameValid(string $filename): bool {
160
-		try {
161
-			$this->validateFilename($filename);
162
-		} catch (\OCP\Files\InvalidPathException) {
163
-			return false;
164
-		}
165
-		return true;
166
-	}
167
-
168
-	/**
169
-	 * @inheritdoc
170
-	 */
171
-	public function validateFilename(string $filename): void {
172
-		$trimmed = trim($filename);
173
-		if ($trimmed === '') {
174
-			throw new EmptyFileNameException();
175
-		}
176
-
177
-		// the special directories . and .. would cause never ending recursion
178
-		// we check the trimmed name here to ensure unexpected trimming will not cause severe issues
179
-		if ($trimmed === '.' || $trimmed === '..') {
180
-			throw new InvalidDirectoryException($this->l10n->t('Dot files are not allowed'));
181
-		}
182
-
183
-		// 255 characters is the limit on common file systems (ext/xfs)
184
-		// oc_filecache has a 250 char length limit for the filename
185
-		if (isset($filename[250])) {
186
-			throw new FileNameTooLongException();
187
-		}
188
-
189
-		if (!$this->database->supports4ByteText()) {
190
-			// verify database - e.g. mysql only 3-byte chars
191
-			if (preg_match('%(?:
28
+    public const INVALID_FILE_TYPE = 100;
29
+
30
+    private IL10N $l10n;
31
+
32
+    /**
33
+     * @var list<string>
34
+     */
35
+    private array $forbiddenNames = [];
36
+
37
+    /**
38
+     * @var list<string>
39
+     */
40
+    private array $forbiddenBasenames = [];
41
+    /**
42
+     * @var list<string>
43
+     */
44
+    private array $forbiddenCharacters = [];
45
+
46
+    /**
47
+     * @var list<string>
48
+     */
49
+    private array $forbiddenExtensions = [];
50
+
51
+    public function __construct(
52
+        IFactory $l10nFactory,
53
+        private IDBConnection $database,
54
+        private IConfig $config,
55
+        private LoggerInterface $logger,
56
+    ) {
57
+        $this->l10n = $l10nFactory->get('core');
58
+    }
59
+
60
+    /**
61
+     * Get a list of reserved filenames that must not be used
62
+     * This list should be checked case-insensitive, all names are returned lowercase.
63
+     * @return list<string>
64
+     * @since 30.0.0
65
+     */
66
+    public function getForbiddenExtensions(): array {
67
+        if (empty($this->forbiddenExtensions)) {
68
+            $forbiddenExtensions = $this->getConfigValue('forbidden_filename_extensions', ['.filepart']);
69
+
70
+            // Always forbid .part files as they are used internally
71
+            $forbiddenExtensions[] = '.part';
72
+
73
+            $this->forbiddenExtensions = array_values($forbiddenExtensions);
74
+        }
75
+        return $this->forbiddenExtensions;
76
+    }
77
+
78
+    /**
79
+     * Get a list of forbidden filename extensions that must not be used
80
+     * This list should be checked case-insensitive, all names are returned lowercase.
81
+     * @return list<string>
82
+     * @since 30.0.0
83
+     */
84
+    public function getForbiddenFilenames(): array {
85
+        if (empty($this->forbiddenNames)) {
86
+            $forbiddenNames = $this->getConfigValue('forbidden_filenames', ['.htaccess']);
87
+
88
+            // Handle legacy config option
89
+            // TODO: Drop with Nextcloud 34
90
+            $legacyForbiddenNames = $this->getConfigValue('blacklisted_files', []);
91
+            if (!empty($legacyForbiddenNames)) {
92
+                $this->logger->warning('System config option "blacklisted_files" is deprecated and will be removed in Nextcloud 34, use "forbidden_filenames" instead.');
93
+            }
94
+            $forbiddenNames = array_merge($legacyForbiddenNames, $forbiddenNames);
95
+
96
+            // Ensure we are having a proper string list
97
+            $this->forbiddenNames = array_values($forbiddenNames);
98
+        }
99
+        return $this->forbiddenNames;
100
+    }
101
+
102
+    /**
103
+     * Get a list of forbidden file basenames that must not be used
104
+     * This list should be checked case-insensitive, all names are returned lowercase.
105
+     * @return list<string>
106
+     * @since 30.0.0
107
+     */
108
+    public function getForbiddenBasenames(): array {
109
+        if (empty($this->forbiddenBasenames)) {
110
+            $forbiddenBasenames = $this->getConfigValue('forbidden_filename_basenames', []);
111
+            // Ensure we are having a proper string list
112
+            $this->forbiddenBasenames = array_values($forbiddenBasenames);
113
+        }
114
+        return $this->forbiddenBasenames;
115
+    }
116
+
117
+    /**
118
+     * Get a list of characters forbidden in filenames
119
+     *
120
+     * Note: Characters in the range [0-31] are always forbidden,
121
+     * even if not inside this list (see OCP\Files\Storage\IStorage::verifyPath).
122
+     *
123
+     * @return list<string>
124
+     * @since 30.0.0
125
+     */
126
+    public function getForbiddenCharacters(): array {
127
+        if (empty($this->forbiddenCharacters)) {
128
+            // Get always forbidden characters
129
+            $forbiddenCharacters = str_split(\OCP\Constants::FILENAME_INVALID_CHARS);
130
+
131
+            // Get admin defined invalid characters
132
+            $additionalChars = $this->config->getSystemValue('forbidden_filename_characters', []);
133
+            if (!is_array($additionalChars)) {
134
+                $this->logger->error('Invalid system config value for "forbidden_filename_characters" is ignored.');
135
+                $additionalChars = [];
136
+            }
137
+            $forbiddenCharacters = array_merge($forbiddenCharacters, $additionalChars);
138
+
139
+            // Handle legacy config option
140
+            // TODO: Drop with Nextcloud 34
141
+            $legacyForbiddenCharacters = $this->config->getSystemValue('forbidden_chars', []);
142
+            if (!is_array($legacyForbiddenCharacters)) {
143
+                $this->logger->error('Invalid system config value for "forbidden_chars" is ignored.');
144
+                $legacyForbiddenCharacters = [];
145
+            }
146
+            if (!empty($legacyForbiddenCharacters)) {
147
+                $this->logger->warning('System config option "forbidden_chars" is deprecated and will be removed in Nextcloud 34, use "forbidden_filename_characters" instead.');
148
+            }
149
+            $forbiddenCharacters = array_merge($legacyForbiddenCharacters, $forbiddenCharacters);
150
+
151
+            $this->forbiddenCharacters = array_values($forbiddenCharacters);
152
+        }
153
+        return $this->forbiddenCharacters;
154
+    }
155
+
156
+    /**
157
+     * @inheritdoc
158
+     */
159
+    public function isFilenameValid(string $filename): bool {
160
+        try {
161
+            $this->validateFilename($filename);
162
+        } catch (\OCP\Files\InvalidPathException) {
163
+            return false;
164
+        }
165
+        return true;
166
+    }
167
+
168
+    /**
169
+     * @inheritdoc
170
+     */
171
+    public function validateFilename(string $filename): void {
172
+        $trimmed = trim($filename);
173
+        if ($trimmed === '') {
174
+            throw new EmptyFileNameException();
175
+        }
176
+
177
+        // the special directories . and .. would cause never ending recursion
178
+        // we check the trimmed name here to ensure unexpected trimming will not cause severe issues
179
+        if ($trimmed === '.' || $trimmed === '..') {
180
+            throw new InvalidDirectoryException($this->l10n->t('Dot files are not allowed'));
181
+        }
182
+
183
+        // 255 characters is the limit on common file systems (ext/xfs)
184
+        // oc_filecache has a 250 char length limit for the filename
185
+        if (isset($filename[250])) {
186
+            throw new FileNameTooLongException();
187
+        }
188
+
189
+        if (!$this->database->supports4ByteText()) {
190
+            // verify database - e.g. mysql only 3-byte chars
191
+            if (preg_match('%(?:
192 192
       \xF0[\x90-\xBF][\x80-\xBF]{2}      # planes 1-3
193 193
     | [\xF1-\xF3][\x80-\xBF]{3}          # planes 4-15
194 194
     | \xF4[\x80-\x8F][\x80-\xBF]{2}      # plane 16
195 195
 )%xs', $filename)) {
196
-				throw new InvalidCharacterInPathException();
197
-			}
198
-		}
199
-
200
-		$this->checkForbiddenName($filename);
201
-
202
-		$this->checkForbiddenExtension($filename);
203
-
204
-		$this->checkForbiddenCharacters($filename);
205
-	}
206
-
207
-	/**
208
-	 * Check if the filename is forbidden
209
-	 * @param string $path Path to check the filename
210
-	 * @return bool True if invalid name, False otherwise
211
-	 */
212
-	public function isForbidden(string $path): bool {
213
-		// We support paths here as this function is also used in some storage internals
214
-		$filename = basename($path);
215
-		$filename = mb_strtolower($filename);
216
-
217
-		if ($filename === '') {
218
-			return false;
219
-		}
220
-
221
-		// Check for forbidden filenames
222
-		$forbiddenNames = $this->getForbiddenFilenames();
223
-		if (in_array($filename, $forbiddenNames)) {
224
-			return true;
225
-		}
226
-
227
-		// Filename is not forbidden
228
-		return false;
229
-	}
230
-
231
-	public function sanitizeFilename(string $name, ?string $charReplacement = null): string {
232
-		$forbiddenCharacters = $this->getForbiddenCharacters();
233
-
234
-		if ($charReplacement === null) {
235
-			$charReplacement = array_diff([' ', '_', '-'], $forbiddenCharacters);
236
-			$charReplacement = reset($charReplacement) ?: '';
237
-		}
238
-		if (mb_strlen($charReplacement) !== 1) {
239
-			throw new \InvalidArgumentException('No or invalid character replacement given');
240
-		}
241
-
242
-		$nameLowercase = mb_strtolower($name);
243
-		foreach ($this->getForbiddenExtensions() as $extension) {
244
-			if (str_ends_with($nameLowercase, $extension)) {
245
-				$name = substr($name, 0, strlen($name) - strlen($extension));
246
-			}
247
-		}
248
-
249
-		$basename = strlen($name) > 1
250
-			? substr($name, 0, strpos($name, '.', 1) ?: null)
251
-			: $name;
252
-		if (in_array(mb_strtolower($basename), $this->getForbiddenBasenames())) {
253
-			$name = str_replace($basename, $this->l10n->t('%1$s (renamed)', [$basename]), $name);
254
-		}
255
-
256
-		if ($name === '') {
257
-			$name = $this->l10n->t('renamed file');
258
-		}
259
-
260
-		if (in_array(mb_strtolower($name), $this->getForbiddenFilenames())) {
261
-			$name = $this->l10n->t('%1$s (renamed)', [$name]);
262
-		}
263
-
264
-		$name = str_replace($forbiddenCharacters, $charReplacement, $name);
265
-		return $name;
266
-	}
267
-
268
-	protected function checkForbiddenName(string $filename): void {
269
-		$filename = mb_strtolower($filename);
270
-		if ($this->isForbidden($filename)) {
271
-			throw new ReservedWordException($this->l10n->t('"%1$s" is a forbidden file or folder name.', [$filename]));
272
-		}
273
-
274
-		// Check for forbidden basenames - basenames are the part of the file until the first dot
275
-		// (except if the dot is the first character as this is then part of the basename "hidden files")
276
-		$basename = substr($filename, 0, strpos($filename, '.', 1) ?: null);
277
-		$forbiddenNames = $this->getForbiddenBasenames();
278
-		if (in_array($basename, $forbiddenNames)) {
279
-			throw new ReservedWordException($this->l10n->t('"%1$s" is a forbidden prefix for file or folder names.', [$filename]));
280
-		}
281
-	}
282
-
283
-
284
-	/**
285
-	 * Check if a filename contains any of the forbidden characters
286
-	 * @param string $filename
287
-	 * @throws InvalidCharacterInPathException
288
-	 */
289
-	protected function checkForbiddenCharacters(string $filename): void {
290
-		$sanitizedFileName = filter_var($filename, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW);
291
-		if ($sanitizedFileName !== $filename) {
292
-			throw new InvalidCharacterInPathException();
293
-		}
294
-
295
-		foreach ($this->getForbiddenCharacters() as $char) {
296
-			if (str_contains($filename, $char)) {
297
-				throw new InvalidCharacterInPathException($this->l10n->t('"%1$s" is not allowed inside a file or folder name.', [$char]));
298
-			}
299
-		}
300
-	}
301
-
302
-	/**
303
-	 * Check if a filename has a forbidden filename extension
304
-	 * @param string $filename The filename to validate
305
-	 * @throws InvalidPathException
306
-	 */
307
-	protected function checkForbiddenExtension(string $filename): void {
308
-		$filename = mb_strtolower($filename);
309
-		// Check for forbidden filename extensions
310
-		$forbiddenExtensions = $this->getForbiddenExtensions();
311
-		foreach ($forbiddenExtensions as $extension) {
312
-			if (str_ends_with($filename, $extension)) {
313
-				if (str_starts_with($extension, '.')) {
314
-					throw new InvalidPathException($this->l10n->t('"%1$s" is a forbidden file type.', [$extension]), self::INVALID_FILE_TYPE);
315
-				} else {
316
-					throw new InvalidPathException($this->l10n->t('Filenames must not end with "%1$s".', [$extension]));
317
-				}
318
-			}
319
-		}
320
-	}
321
-
322
-	/**
323
-	 * Helper to get lower case list from config with validation
324
-	 * @return string[]
325
-	 */
326
-	private function getConfigValue(string $key, array $fallback): array {
327
-		$values = $this->config->getSystemValue($key, $fallback);
328
-		if (!is_array($values)) {
329
-			$this->logger->error('Invalid system config value for "' . $key . '" is ignored.');
330
-			$values = $fallback;
331
-		}
332
-
333
-		return array_map(mb_strtolower(...), $values);
334
-	}
196
+                throw new InvalidCharacterInPathException();
197
+            }
198
+        }
199
+
200
+        $this->checkForbiddenName($filename);
201
+
202
+        $this->checkForbiddenExtension($filename);
203
+
204
+        $this->checkForbiddenCharacters($filename);
205
+    }
206
+
207
+    /**
208
+     * Check if the filename is forbidden
209
+     * @param string $path Path to check the filename
210
+     * @return bool True if invalid name, False otherwise
211
+     */
212
+    public function isForbidden(string $path): bool {
213
+        // We support paths here as this function is also used in some storage internals
214
+        $filename = basename($path);
215
+        $filename = mb_strtolower($filename);
216
+
217
+        if ($filename === '') {
218
+            return false;
219
+        }
220
+
221
+        // Check for forbidden filenames
222
+        $forbiddenNames = $this->getForbiddenFilenames();
223
+        if (in_array($filename, $forbiddenNames)) {
224
+            return true;
225
+        }
226
+
227
+        // Filename is not forbidden
228
+        return false;
229
+    }
230
+
231
+    public function sanitizeFilename(string $name, ?string $charReplacement = null): string {
232
+        $forbiddenCharacters = $this->getForbiddenCharacters();
233
+
234
+        if ($charReplacement === null) {
235
+            $charReplacement = array_diff([' ', '_', '-'], $forbiddenCharacters);
236
+            $charReplacement = reset($charReplacement) ?: '';
237
+        }
238
+        if (mb_strlen($charReplacement) !== 1) {
239
+            throw new \InvalidArgumentException('No or invalid character replacement given');
240
+        }
241
+
242
+        $nameLowercase = mb_strtolower($name);
243
+        foreach ($this->getForbiddenExtensions() as $extension) {
244
+            if (str_ends_with($nameLowercase, $extension)) {
245
+                $name = substr($name, 0, strlen($name) - strlen($extension));
246
+            }
247
+        }
248
+
249
+        $basename = strlen($name) > 1
250
+            ? substr($name, 0, strpos($name, '.', 1) ?: null)
251
+            : $name;
252
+        if (in_array(mb_strtolower($basename), $this->getForbiddenBasenames())) {
253
+            $name = str_replace($basename, $this->l10n->t('%1$s (renamed)', [$basename]), $name);
254
+        }
255
+
256
+        if ($name === '') {
257
+            $name = $this->l10n->t('renamed file');
258
+        }
259
+
260
+        if (in_array(mb_strtolower($name), $this->getForbiddenFilenames())) {
261
+            $name = $this->l10n->t('%1$s (renamed)', [$name]);
262
+        }
263
+
264
+        $name = str_replace($forbiddenCharacters, $charReplacement, $name);
265
+        return $name;
266
+    }
267
+
268
+    protected function checkForbiddenName(string $filename): void {
269
+        $filename = mb_strtolower($filename);
270
+        if ($this->isForbidden($filename)) {
271
+            throw new ReservedWordException($this->l10n->t('"%1$s" is a forbidden file or folder name.', [$filename]));
272
+        }
273
+
274
+        // Check for forbidden basenames - basenames are the part of the file until the first dot
275
+        // (except if the dot is the first character as this is then part of the basename "hidden files")
276
+        $basename = substr($filename, 0, strpos($filename, '.', 1) ?: null);
277
+        $forbiddenNames = $this->getForbiddenBasenames();
278
+        if (in_array($basename, $forbiddenNames)) {
279
+            throw new ReservedWordException($this->l10n->t('"%1$s" is a forbidden prefix for file or folder names.', [$filename]));
280
+        }
281
+    }
282
+
283
+
284
+    /**
285
+     * Check if a filename contains any of the forbidden characters
286
+     * @param string $filename
287
+     * @throws InvalidCharacterInPathException
288
+     */
289
+    protected function checkForbiddenCharacters(string $filename): void {
290
+        $sanitizedFileName = filter_var($filename, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW);
291
+        if ($sanitizedFileName !== $filename) {
292
+            throw new InvalidCharacterInPathException();
293
+        }
294
+
295
+        foreach ($this->getForbiddenCharacters() as $char) {
296
+            if (str_contains($filename, $char)) {
297
+                throw new InvalidCharacterInPathException($this->l10n->t('"%1$s" is not allowed inside a file or folder name.', [$char]));
298
+            }
299
+        }
300
+    }
301
+
302
+    /**
303
+     * Check if a filename has a forbidden filename extension
304
+     * @param string $filename The filename to validate
305
+     * @throws InvalidPathException
306
+     */
307
+    protected function checkForbiddenExtension(string $filename): void {
308
+        $filename = mb_strtolower($filename);
309
+        // Check for forbidden filename extensions
310
+        $forbiddenExtensions = $this->getForbiddenExtensions();
311
+        foreach ($forbiddenExtensions as $extension) {
312
+            if (str_ends_with($filename, $extension)) {
313
+                if (str_starts_with($extension, '.')) {
314
+                    throw new InvalidPathException($this->l10n->t('"%1$s" is a forbidden file type.', [$extension]), self::INVALID_FILE_TYPE);
315
+                } else {
316
+                    throw new InvalidPathException($this->l10n->t('Filenames must not end with "%1$s".', [$extension]));
317
+                }
318
+            }
319
+        }
320
+    }
321
+
322
+    /**
323
+     * Helper to get lower case list from config with validation
324
+     * @return string[]
325
+     */
326
+    private function getConfigValue(string $key, array $fallback): array {
327
+        $values = $this->config->getSystemValue($key, $fallback);
328
+        if (!is_array($values)) {
329
+            $this->logger->error('Invalid system config value for "' . $key . '" is ignored.');
330
+            $values = $fallback;
331
+        }
332
+
333
+        return array_map(mb_strtolower(...), $values);
334
+    }
335 335
 };
Please login to merge, or discard this patch.
apps/files/lib/Command/SanitizeFilenames.php 1 patch
Indentation   +119 added lines, -119 removed lines patch added patch discarded remove patch
@@ -26,125 +26,125 @@
 block discarded – undo
26 26
 
27 27
 class SanitizeFilenames extends Base {
28 28
 
29
-	private OutputInterface $output;
30
-	private ?string $charReplacement;
31
-	private bool $dryRun;
32
-
33
-	public function __construct(
34
-		private IUserManager $userManager,
35
-		private IRootFolder $rootFolder,
36
-		private IUserSession $session,
37
-		private IFactory $l10nFactory,
38
-		private FilenameValidator $filenameValidator,
39
-	) {
40
-		parent::__construct();
41
-	}
42
-
43
-	protected function configure(): void {
44
-		parent::configure();
45
-
46
-		$this
47
-			->setName('files:sanitize-filenames')
48
-			->setDescription('Renames files to match naming constraints')
49
-			->addArgument(
50
-				'user_id',
51
-				InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
52
-				'will only rename files the given user(s) have access to'
53
-			)
54
-			->addOption(
55
-				'dry-run',
56
-				mode: InputOption::VALUE_NONE,
57
-				description: 'Do not actually rename any files but just check filenames.',
58
-			)
59
-			->addOption(
60
-				'char-replacement',
61
-				'c',
62
-				mode: InputOption::VALUE_REQUIRED,
63
-				description: 'Replacement for invalid character (by default space, underscore or dash is used)',
64
-			);
29
+    private OutputInterface $output;
30
+    private ?string $charReplacement;
31
+    private bool $dryRun;
32
+
33
+    public function __construct(
34
+        private IUserManager $userManager,
35
+        private IRootFolder $rootFolder,
36
+        private IUserSession $session,
37
+        private IFactory $l10nFactory,
38
+        private FilenameValidator $filenameValidator,
39
+    ) {
40
+        parent::__construct();
41
+    }
42
+
43
+    protected function configure(): void {
44
+        parent::configure();
45
+
46
+        $this
47
+            ->setName('files:sanitize-filenames')
48
+            ->setDescription('Renames files to match naming constraints')
49
+            ->addArgument(
50
+                'user_id',
51
+                InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
52
+                'will only rename files the given user(s) have access to'
53
+            )
54
+            ->addOption(
55
+                'dry-run',
56
+                mode: InputOption::VALUE_NONE,
57
+                description: 'Do not actually rename any files but just check filenames.',
58
+            )
59
+            ->addOption(
60
+                'char-replacement',
61
+                'c',
62
+                mode: InputOption::VALUE_REQUIRED,
63
+                description: 'Replacement for invalid character (by default space, underscore or dash is used)',
64
+            );
65 65
 			
66
-	}
67
-
68
-	protected function execute(InputInterface $input, OutputInterface $output): int {
69
-		$this->charReplacement = $input->getOption('char-replacement');
70
-		// check if replacement is needed
71
-		$c = $this->filenameValidator->getForbiddenCharacters();
72
-		if (count($c) > 0) {
73
-			try {
74
-				$this->filenameValidator->sanitizeFilename($c[0], $this->charReplacement);
75
-			} catch (\InvalidArgumentException) {
76
-				if ($this->charReplacement === null) {
77
-					$output->writeln('<error>Character replacement required</error>');
78
-				} else {
79
-					$output->writeln('<error>Invalid character replacement given</error>');
80
-				}
81
-				return 1;
82
-			}
83
-		}
84
-
85
-		$this->dryRun = $input->getOption('dry-run');
86
-		if ($this->dryRun) {
87
-			$output->writeln('<info>Dry run is enabled, no actual renaming will be applied.</>');
88
-		}
89
-
90
-		$this->output = $output;
91
-		$users = $input->getArgument('user_id');
92
-		if (!empty($users)) {
93
-			foreach ($users as $userId) {
94
-				$user = $this->userManager->get($userId);
95
-				if ($user === null) {
96
-					$output->writeln("<error>User '$userId' does not exist - skipping</>");
97
-					continue;
98
-				}
99
-				$this->sanitizeUserFiles($user);
100
-			}
101
-		} else {
102
-			$this->userManager->callForSeenUsers($this->sanitizeUserFiles(...));
103
-		}
104
-		return self::SUCCESS;
105
-	}
106
-
107
-	private function sanitizeUserFiles(IUser $user): void {
108
-		// Set an active user so that event listeners can correctly work (e.g. files versions)
109
-		$this->session->setVolatileActiveUser($user);
110
-
111
-		$this->output->writeln('<info>Analyzing files of ' . $user->getUID() . '</>');
112
-
113
-		$folder = $this->rootFolder->getUserFolder($user->getUID());
114
-		$this->sanitizeFiles($folder);
115
-	}
116
-
117
-	private function sanitizeFiles(Folder $folder): void {
118
-		foreach ($folder->getDirectoryListing() as $node) {
119
-			$this->output->writeln('scanning: ' . $node->getPath(), OutputInterface::VERBOSITY_VERBOSE);
120
-
121
-			try {
122
-				$oldName = $node->getName();
123
-				$newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement);
124
-				if ($oldName !== $newName) {
125
-					$newName = $folder->getNonExistingName($newName);
126
-					$path = rtrim(dirname($node->getPath()), '/');
127
-
128
-					if (!$this->dryRun) {
129
-						$node->move("$path/$newName");
130
-					} elseif (!$folder->isCreatable()) {
131
-						// simulate error for dry run
132
-						throw new NotPermittedException();
133
-					}
134
-					$this->output->writeln('renamed: "' . $oldName . '" to "' . $newName . '"');
135
-				}
136
-			} catch (LockedException) {
137
-				$this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (file is locked)</>');
138
-			} catch (NotPermittedException) {
139
-				$this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (no permissions)</>');
140
-			} catch (Exception) {
141
-				$this->output->writeln('<error>failed: ' . $node->getPath() . '</>');
142
-			}
143
-
144
-			if ($node instanceof Folder) {
145
-				$this->sanitizeFiles($node);
146
-			}
147
-		}
148
-	}
66
+    }
67
+
68
+    protected function execute(InputInterface $input, OutputInterface $output): int {
69
+        $this->charReplacement = $input->getOption('char-replacement');
70
+        // check if replacement is needed
71
+        $c = $this->filenameValidator->getForbiddenCharacters();
72
+        if (count($c) > 0) {
73
+            try {
74
+                $this->filenameValidator->sanitizeFilename($c[0], $this->charReplacement);
75
+            } catch (\InvalidArgumentException) {
76
+                if ($this->charReplacement === null) {
77
+                    $output->writeln('<error>Character replacement required</error>');
78
+                } else {
79
+                    $output->writeln('<error>Invalid character replacement given</error>');
80
+                }
81
+                return 1;
82
+            }
83
+        }
84
+
85
+        $this->dryRun = $input->getOption('dry-run');
86
+        if ($this->dryRun) {
87
+            $output->writeln('<info>Dry run is enabled, no actual renaming will be applied.</>');
88
+        }
89
+
90
+        $this->output = $output;
91
+        $users = $input->getArgument('user_id');
92
+        if (!empty($users)) {
93
+            foreach ($users as $userId) {
94
+                $user = $this->userManager->get($userId);
95
+                if ($user === null) {
96
+                    $output->writeln("<error>User '$userId' does not exist - skipping</>");
97
+                    continue;
98
+                }
99
+                $this->sanitizeUserFiles($user);
100
+            }
101
+        } else {
102
+            $this->userManager->callForSeenUsers($this->sanitizeUserFiles(...));
103
+        }
104
+        return self::SUCCESS;
105
+    }
106
+
107
+    private function sanitizeUserFiles(IUser $user): void {
108
+        // Set an active user so that event listeners can correctly work (e.g. files versions)
109
+        $this->session->setVolatileActiveUser($user);
110
+
111
+        $this->output->writeln('<info>Analyzing files of ' . $user->getUID() . '</>');
112
+
113
+        $folder = $this->rootFolder->getUserFolder($user->getUID());
114
+        $this->sanitizeFiles($folder);
115
+    }
116
+
117
+    private function sanitizeFiles(Folder $folder): void {
118
+        foreach ($folder->getDirectoryListing() as $node) {
119
+            $this->output->writeln('scanning: ' . $node->getPath(), OutputInterface::VERBOSITY_VERBOSE);
120
+
121
+            try {
122
+                $oldName = $node->getName();
123
+                $newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement);
124
+                if ($oldName !== $newName) {
125
+                    $newName = $folder->getNonExistingName($newName);
126
+                    $path = rtrim(dirname($node->getPath()), '/');
127
+
128
+                    if (!$this->dryRun) {
129
+                        $node->move("$path/$newName");
130
+                    } elseif (!$folder->isCreatable()) {
131
+                        // simulate error for dry run
132
+                        throw new NotPermittedException();
133
+                    }
134
+                    $this->output->writeln('renamed: "' . $oldName . '" to "' . $newName . '"');
135
+                }
136
+            } catch (LockedException) {
137
+                $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (file is locked)</>');
138
+            } catch (NotPermittedException) {
139
+                $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (no permissions)</>');
140
+            } catch (Exception) {
141
+                $this->output->writeln('<error>failed: ' . $node->getPath() . '</>');
142
+            }
143
+
144
+            if ($node instanceof Folder) {
145
+                $this->sanitizeFiles($node);
146
+            }
147
+        }
148
+    }
149 149
 
150 150
 }
Please login to merge, or discard this patch.
tests/lib/Files/FilenameValidatorTest.php 1 patch
Indentation   +482 added lines, -482 removed lines patch added patch discarded remove patch
@@ -26,486 +26,486 @@
 block discarded – undo
26 26
 
27 27
 class FilenameValidatorTest extends TestCase {
28 28
 
29
-	protected IFactory&MockObject $l10n;
30
-	protected IConfig&MockObject $config;
31
-	protected IDBConnection&MockObject $database;
32
-	protected LoggerInterface&MockObject $logger;
33
-
34
-	protected function setUp(): void {
35
-		parent::setUp();
36
-		$l10n = $this->createMock(IL10N::class);
37
-		$l10n->method('t')
38
-			->willReturnCallback(fn ($string, $params) => sprintf($string, ...$params));
39
-		$this->l10n = $this->createMock(IFactory::class);
40
-		$this->l10n
41
-			->method('get')
42
-			->with('core')
43
-			->willReturn($l10n);
44
-
45
-		$this->config = $this->createMock(IConfig::class);
46
-		$this->logger = $this->createMock(LoggerInterface::class);
47
-		$this->database = $this->createMock(IDBConnection::class);
48
-		$this->database->method('supports4ByteText')->willReturn(true);
49
-	}
50
-
51
-	/**
52
-	 * @dataProvider dataValidateFilename
53
-	 */
54
-	public function testValidateFilename(
55
-		string $filename,
56
-		array $forbiddenNames,
57
-		array $forbiddenBasenames,
58
-		array $forbiddenExtensions,
59
-		array $forbiddenCharacters,
60
-		?string $exception,
61
-	): void {
62
-		/** @var FilenameValidator&MockObject */
63
-		$validator = $this->getMockBuilder(FilenameValidator::class)
64
-			->onlyMethods([
65
-				'getForbiddenBasenames',
66
-				'getForbiddenCharacters',
67
-				'getForbiddenExtensions',
68
-				'getForbiddenFilenames',
69
-			])
70
-			->setConstructorArgs([$this->l10n, $this->database, $this->config, $this->logger])
71
-			->getMock();
72
-
73
-		$validator->method('getForbiddenBasenames')
74
-			->willReturn($forbiddenBasenames);
75
-		$validator->method('getForbiddenCharacters')
76
-			->willReturn($forbiddenCharacters);
77
-		$validator->method('getForbiddenExtensions')
78
-			->willReturn($forbiddenExtensions);
79
-		$validator->method('getForbiddenFilenames')
80
-			->willReturn($forbiddenNames);
81
-
82
-		if ($exception !== null) {
83
-			$this->expectException($exception);
84
-		} else {
85
-			$this->expectNotToPerformAssertions();
86
-		}
87
-		$validator->validateFilename($filename);
88
-	}
89
-
90
-	/**
91
-	 * @dataProvider dataValidateFilename
92
-	 */
93
-	public function testIsFilenameValid(
94
-		string $filename,
95
-		array $forbiddenNames,
96
-		array $forbiddenBasenames,
97
-		array $forbiddenExtensions,
98
-		array $forbiddenCharacters,
99
-		?string $exception,
100
-	): void {
101
-		/** @var FilenameValidator&MockObject */
102
-		$validator = $this->getMockBuilder(FilenameValidator::class)
103
-			->onlyMethods([
104
-				'getForbiddenBasenames',
105
-				'getForbiddenExtensions',
106
-				'getForbiddenFilenames',
107
-				'getForbiddenCharacters',
108
-			])
109
-			->setConstructorArgs([$this->l10n, $this->database, $this->config, $this->logger])
110
-			->getMock();
111
-
112
-		$validator->method('getForbiddenBasenames')
113
-			->willReturn($forbiddenBasenames);
114
-		$validator->method('getForbiddenCharacters')
115
-			->willReturn($forbiddenCharacters);
116
-		$validator->method('getForbiddenExtensions')
117
-			->willReturn($forbiddenExtensions);
118
-		$validator->method('getForbiddenFilenames')
119
-			->willReturn($forbiddenNames);
120
-
121
-
122
-		$this->assertEquals($exception === null, $validator->isFilenameValid($filename));
123
-	}
124
-
125
-	public function dataValidateFilename(): array {
126
-		return [
127
-			'valid name' => [
128
-				'a: b.txt', ['.htaccess'], [], [], [], null
129
-			],
130
-			'forbidden name in the middle is ok' => [
131
-				'a.htaccess.txt', ['.htaccess'], [], [], [], null
132
-			],
133
-			'valid name with some more parameters' => [
134
-				'a: b.txt', ['.htaccess'], [], ['exe'], ['~'], null
135
-			],
136
-			'valid name checks only the full name' => [
137
-				'.htaccess.sample', ['.htaccess'], [], [], [], null
138
-			],
139
-			'forbidden name' => [
140
-				'.htaccess', ['.htaccess'], [], [], [], ReservedWordException::class
141
-			],
142
-			'forbidden name - name is case insensitive' => [
143
-				'COM1', ['.htaccess', 'com1'], [], [], [], ReservedWordException::class
144
-			],
145
-			'forbidden basename' => [
146
-				// needed for Windows namespaces
147
-				'com1.suffix', ['.htaccess'], ['com1'], [], [], ReservedWordException::class
148
-			],
149
-			'forbidden basename case insensitive' => [
150
-				// needed for Windows namespaces
151
-				'COM1.suffix', ['.htaccess'], ['com1'], [], [], ReservedWordException::class
152
-			],
153
-			'forbidden basename for hidden files' => [
154
-				// needed for Windows namespaces
155
-				'.thumbs.db', ['.htaccess'], ['.thumbs'], [], [], ReservedWordException::class
156
-			],
157
-			'invalid character' => [
158
-				'a: b.txt', ['.htaccess'], [], [], [':'], InvalidCharacterInPathException::class
159
-			],
160
-			'invalid path' => [
161
-				'../../foo.bar', ['.htaccess'], [], [], ['/', '\\'], InvalidCharacterInPathException::class,
162
-			],
163
-			'invalid extension' => [
164
-				'a: b.txt', ['.htaccess'], [], ['.txt'], [], InvalidPathException::class
165
-			],
166
-			'invalid extension case insensitive' => [
167
-				'a: b.TXT', ['.htaccess'], [], ['.txt'], [], InvalidPathException::class
168
-			],
169
-			'empty filename' => [
170
-				'', [], [], [], [], EmptyFileNameException::class
171
-			],
172
-			'reserved unix name "."' => [
173
-				'.', [], [], [], [], InvalidDirectoryException::class
174
-			],
175
-			'reserved unix name ".."' => [
176
-				'..', [], [], [], [], InvalidDirectoryException::class
177
-			],
178
-			'weird but valid tripple dot name' => [
179
-				'...', [], [], [], [], null // is valid
180
-			],
181
-			'too long filename "."' => [
182
-				str_repeat('a', 251), [], [], [], [], FileNameTooLongException::class
183
-			],
184
-			// make sure to not split the list entries as they migh contain Unicode sequences
185
-			// in this example the "face in clouds" emoji contains the clouds emoji so only having clouds is ok
186
-			['
Please login to merge, or discard this patch.