FilenameValidatorTest.php 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. <?php
  2. declare(strict_types=1);
  3. /*!
  4. * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace Test\Files;
  8. use OC\Files\FilenameValidator;
  9. use OCP\Files\EmptyFileNameException;
  10. use OCP\Files\FileNameTooLongException;
  11. use OCP\Files\InvalidCharacterInPathException;
  12. use OCP\Files\InvalidDirectoryException;
  13. use OCP\Files\InvalidPathException;
  14. use OCP\Files\ReservedWordException;
  15. use OCP\IConfig;
  16. use OCP\IDBConnection;
  17. use OCP\IL10N;
  18. use OCP\L10N\IFactory;
  19. use PHPUnit\Framework\MockObject\MockObject;
  20. use Psr\Log\LoggerInterface;
  21. use Test\TestCase;
  22. class FilenameValidatorTest extends TestCase {
  23. protected IFactory&MockObject $l10n;
  24. protected IConfig&MockObject $config;
  25. protected IDBConnection&MockObject $database;
  26. protected LoggerInterface&MockObject $logger;
  27. protected function setUp(): void {
  28. parent::setUp();
  29. $l10n = $this->createMock(IL10N::class);
  30. $l10n->method('t')
  31. ->willReturnCallback(fn ($string, $params) => sprintf($string, ...$params));
  32. $this->l10n = $this->createMock(IFactory::class);
  33. $this->l10n
  34. ->method('get')
  35. ->with('core')
  36. ->willReturn($l10n);
  37. $this->config = $this->createMock(IConfig::class);
  38. $this->logger = $this->createMock(LoggerInterface::class);
  39. $this->database = $this->createMock(IDBConnection::class);
  40. $this->database->method('supports4ByteText')->willReturn(true);
  41. }
  42. /**
  43. * @dataProvider dataValidateFilename
  44. */
  45. public function testValidateFilename(
  46. string $filename,
  47. array $forbiddenNames,
  48. array $forbiddenBasenames,
  49. array $forbiddenExtensions,
  50. array $forbiddenCharacters,
  51. ?string $exception,
  52. ): void {
  53. /** @var FilenameValidator&MockObject */
  54. $validator = $this->getMockBuilder(FilenameValidator::class)
  55. ->onlyMethods([
  56. 'getForbiddenBasenames',
  57. 'getForbiddenCharacters',
  58. 'getForbiddenExtensions',
  59. 'getForbiddenFilenames',
  60. ])
  61. ->setConstructorArgs([$this->l10n, $this->database, $this->config, $this->logger])
  62. ->getMock();
  63. $validator->method('getForbiddenBasenames')
  64. ->willReturn($forbiddenBasenames);
  65. $validator->method('getForbiddenCharacters')
  66. ->willReturn($forbiddenCharacters);
  67. $validator->method('getForbiddenExtensions')
  68. ->willReturn($forbiddenExtensions);
  69. $validator->method('getForbiddenFilenames')
  70. ->willReturn($forbiddenNames);
  71. if ($exception !== null) {
  72. $this->expectException($exception);
  73. } else {
  74. $this->expectNotToPerformAssertions();
  75. }
  76. $validator->validateFilename($filename);
  77. }
  78. /**
  79. * @dataProvider dataValidateFilename
  80. */
  81. public function testIsFilenameValid(
  82. string $filename,
  83. array $forbiddenNames,
  84. array $forbiddenBasenames,
  85. array $forbiddenExtensions,
  86. array $forbiddenCharacters,
  87. ?string $exception,
  88. ): void {
  89. /** @var FilenameValidator&MockObject */
  90. $validator = $this->getMockBuilder(FilenameValidator::class)
  91. ->onlyMethods([
  92. 'getForbiddenBasenames',
  93. 'getForbiddenExtensions',
  94. 'getForbiddenFilenames',
  95. 'getForbiddenCharacters',
  96. ])
  97. ->setConstructorArgs([$this->l10n, $this->database, $this->config, $this->logger])
  98. ->getMock();
  99. $validator->method('getForbiddenBasenames')
  100. ->willReturn($forbiddenBasenames);
  101. $validator->method('getForbiddenCharacters')
  102. ->willReturn($forbiddenCharacters);
  103. $validator->method('getForbiddenExtensions')
  104. ->willReturn($forbiddenExtensions);
  105. $validator->method('getForbiddenFilenames')
  106. ->willReturn($forbiddenNames);
  107. $this->assertEquals($exception === null, $validator->isFilenameValid($filename));
  108. }
  109. public function dataValidateFilename(): array {
  110. return [
  111. 'valid name' => [
  112. 'a: b.txt', ['.htaccess'], [], [], [], null
  113. ],
  114. 'forbidden name in the middle is ok' => [
  115. 'a.htaccess.txt', ['.htaccess'], [], [], [], null
  116. ],
  117. 'valid name with some more parameters' => [
  118. 'a: b.txt', ['.htaccess'], [], ['exe'], ['~'], null
  119. ],
  120. 'valid name checks only the full name' => [
  121. '.htaccess.sample', ['.htaccess'], [], [], [], null
  122. ],
  123. 'forbidden name' => [
  124. '.htaccess', ['.htaccess'], [], [], [], ReservedWordException::class
  125. ],
  126. 'forbidden name - name is case insensitive' => [
  127. 'COM1', ['.htaccess', 'com1'], [], [], [], ReservedWordException::class
  128. ],
  129. 'forbidden basename' => [
  130. // needed for Windows namespaces
  131. 'com1.suffix', ['.htaccess'], ['com1'], [], [], ReservedWordException::class
  132. ],
  133. 'forbidden basename for hidden files' => [
  134. // needed for Windows namespaces
  135. '.thumbs.db', ['.htaccess'], ['.thumbs'], [], [], ReservedWordException::class
  136. ],
  137. 'invalid character' => [
  138. 'a: b.txt', ['.htaccess'], [], [], [':'], InvalidCharacterInPathException::class
  139. ],
  140. 'invalid path' => [
  141. '../../foo.bar', ['.htaccess'], [], [], ['/', '\\'], InvalidCharacterInPathException::class,
  142. ],
  143. 'invalid extension' => [
  144. 'a: b.txt', ['.htaccess'], [], ['.txt'], [], InvalidPathException::class
  145. ],
  146. 'empty filename' => [
  147. '', [], [], [], [], EmptyFileNameException::class
  148. ],
  149. 'reserved unix name "."' => [
  150. '.', [], [], [], [], InvalidDirectoryException::class
  151. ],
  152. 'reserved unix name ".."' => [
  153. '..', [], [], [], [], InvalidDirectoryException::class
  154. ],
  155. 'weird but valid tripple dot name' => [
  156. '...', [], [], [], [], null // is valid
  157. ],
  158. 'too long filename "."' => [
  159. str_repeat('a', 251), [], [], [], [], FileNameTooLongException::class
  160. ],
  161. // make sure to not split the list entries as they migh contain Unicode sequences
  162. // in this example the "face in clouds" emoji contains the clouds emoji so only having clouds is ok
  163. ['🌫️.txt', ['.htaccess'], [], [], ['😶‍🌫️'], null],
  164. // This is the reverse: clouds are forbidden -> so is also the face in the clouds emoji
  165. ['😶‍🌫️.txt', ['.htaccess'], [], [], ['🌫️'], InvalidCharacterInPathException::class],
  166. ];
  167. }
  168. /**
  169. * @dataProvider data4ByteUnicode
  170. */
  171. public function testDatabaseDoesNotSupport4ByteText($filename): void {
  172. $database = $this->createMock(IDBConnection::class);
  173. $database->expects($this->once())
  174. ->method('supports4ByteText')
  175. ->willReturn(false);
  176. $this->expectException(InvalidCharacterInPathException::class);
  177. $validator = new FilenameValidator($this->l10n, $database, $this->config, $this->logger);
  178. $validator->validateFilename($filename);
  179. }
  180. public function data4ByteUnicode(): array {
  181. return [
  182. ['plane 1 𐪅'],
  183. ['emoji 😶‍🌫️'],
  184. ];
  185. }
  186. /**
  187. * @dataProvider dataInvalidAsciiCharacters
  188. */
  189. public function testInvalidAsciiCharactersAreAlwaysForbidden(string $filename): void {
  190. $this->expectException(InvalidPathException::class);
  191. $validator = new FilenameValidator($this->l10n, $this->database, $this->config, $this->logger);
  192. $validator->validateFilename($filename);
  193. }
  194. public function dataInvalidAsciiCharacters(): array {
  195. return [
  196. [\chr(0)],
  197. [\chr(1)],
  198. [\chr(2)],
  199. [\chr(3)],
  200. [\chr(4)],
  201. [\chr(5)],
  202. [\chr(6)],
  203. [\chr(7)],
  204. [\chr(8)],
  205. [\chr(9)],
  206. [\chr(10)],
  207. [\chr(11)],
  208. [\chr(12)],
  209. [\chr(13)],
  210. [\chr(14)],
  211. [\chr(15)],
  212. [\chr(16)],
  213. [\chr(17)],
  214. [\chr(18)],
  215. [\chr(19)],
  216. [\chr(20)],
  217. [\chr(21)],
  218. [\chr(22)],
  219. [\chr(23)],
  220. [\chr(24)],
  221. [\chr(25)],
  222. [\chr(26)],
  223. [\chr(27)],
  224. [\chr(28)],
  225. [\chr(29)],
  226. [\chr(30)],
  227. [\chr(31)],
  228. ];
  229. }
  230. /**
  231. * @dataProvider dataIsForbidden
  232. */
  233. public function testIsForbidden(string $filename, array $forbiddenNames, array $forbiddenBasenames, bool $expected): void {
  234. /** @var FilenameValidator&MockObject */
  235. $validator = $this->getMockBuilder(FilenameValidator::class)
  236. ->onlyMethods(['getForbiddenFilenames', 'getForbiddenBasenames'])
  237. ->setConstructorArgs([$this->l10n, $this->database, $this->config, $this->logger])
  238. ->getMock();
  239. $validator->method('getForbiddenBasenames')
  240. ->willReturn($forbiddenBasenames);
  241. $validator->method('getForbiddenFilenames')
  242. ->willReturn($forbiddenNames);
  243. $this->assertEquals($expected, $validator->isForbidden($filename));
  244. }
  245. public function dataIsForbidden(): array {
  246. return [
  247. 'valid name' => [
  248. 'a: b.txt', ['.htaccess'], [], false
  249. ],
  250. 'valid name with some more parameters' => [
  251. 'a: b.txt', ['.htaccess'], [], false
  252. ],
  253. 'valid name as only full forbidden should be matched' => [
  254. '.htaccess.sample', ['.htaccess'], [], false,
  255. ],
  256. 'forbidden name' => [
  257. '.htaccess', ['.htaccess'], [], true
  258. ],
  259. 'forbidden name - name is case insensitive' => [
  260. 'COM1', ['.htaccess', 'com1'], [], true,
  261. ],
  262. 'forbidden name - basename is checked' => [
  263. // needed for Windows namespaces
  264. 'com1.suffix', ['.htaccess'], ['com1'], true
  265. ],
  266. 'forbidden name - basename is checked also with multiple extensions' => [
  267. // needed for Windows namespaces
  268. 'com1.tar.gz', ['.htaccess'], ['com1'], true
  269. ],
  270. ];
  271. }
  272. }