AmazonS3.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OCA\Files_External\Lib\Storage;
  8. use Aws\S3\Exception\S3Exception;
  9. use Icewind\Streams\CallbackWrapper;
  10. use Icewind\Streams\IteratorDirectory;
  11. use OC\Files\Cache\CacheEntry;
  12. use OC\Files\ObjectStore\S3ConnectionTrait;
  13. use OC\Files\ObjectStore\S3ObjectTrait;
  14. use OCP\Cache\CappedMemoryCache;
  15. use OCP\Constants;
  16. use OCP\Files\FileInfo;
  17. use OCP\Files\IMimeTypeDetector;
  18. use OCP\ICache;
  19. use OCP\ICacheFactory;
  20. use OCP\Server;
  21. use Psr\Log\LoggerInterface;
  22. class AmazonS3 extends \OC\Files\Storage\Common {
  23. use S3ConnectionTrait;
  24. use S3ObjectTrait;
  25. private LoggerInterface $logger;
  26. public function needsPartFile() {
  27. return false;
  28. }
  29. /** @var CappedMemoryCache<array|false> */
  30. private CappedMemoryCache $objectCache;
  31. /** @var CappedMemoryCache<bool> */
  32. private CappedMemoryCache $directoryCache;
  33. /** @var CappedMemoryCache<array> */
  34. private CappedMemoryCache $filesCache;
  35. private IMimeTypeDetector $mimeDetector;
  36. private ?bool $versioningEnabled = null;
  37. private ICache $memCache;
  38. public function __construct($parameters) {
  39. parent::__construct($parameters);
  40. $this->parseParams($parameters);
  41. $this->id = 'amazon::external::' . md5($this->params['hostname'] . ':' . $this->params['bucket'] . ':' . $this->params['key']);
  42. $this->objectCache = new CappedMemoryCache();
  43. $this->directoryCache = new CappedMemoryCache();
  44. $this->filesCache = new CappedMemoryCache();
  45. $this->mimeDetector = Server::get(IMimeTypeDetector::class);
  46. /** @var ICacheFactory $cacheFactory */
  47. $cacheFactory = Server::get(ICacheFactory::class);
  48. $this->memCache = $cacheFactory->createLocal('s3-external');
  49. $this->logger = Server::get(LoggerInterface::class);
  50. }
  51. /**
  52. * @param string $path
  53. * @return string correctly encoded path
  54. */
  55. private function normalizePath($path) {
  56. $path = trim($path, '/');
  57. if (!$path) {
  58. $path = '.';
  59. }
  60. return $path;
  61. }
  62. private function isRoot($path) {
  63. return $path === '.';
  64. }
  65. private function cleanKey($path) {
  66. if ($this->isRoot($path)) {
  67. return '/';
  68. }
  69. return $path;
  70. }
  71. private function clearCache() {
  72. $this->objectCache = new CappedMemoryCache();
  73. $this->directoryCache = new CappedMemoryCache();
  74. $this->filesCache = new CappedMemoryCache();
  75. }
  76. private function invalidateCache($key) {
  77. unset($this->objectCache[$key]);
  78. $keys = array_keys($this->objectCache->getData());
  79. $keyLength = strlen($key);
  80. foreach ($keys as $existingKey) {
  81. if (substr($existingKey, 0, $keyLength) === $key) {
  82. unset($this->objectCache[$existingKey]);
  83. }
  84. }
  85. unset($this->filesCache[$key]);
  86. $keys = array_keys($this->directoryCache->getData());
  87. $keyLength = strlen($key);
  88. foreach ($keys as $existingKey) {
  89. if (substr($existingKey, 0, $keyLength) === $key) {
  90. unset($this->directoryCache[$existingKey]);
  91. }
  92. }
  93. unset($this->directoryCache[$key]);
  94. }
  95. /**
  96. * @return array|false
  97. */
  98. private function headObject(string $key) {
  99. if (!isset($this->objectCache[$key])) {
  100. try {
  101. $this->objectCache[$key] = $this->getConnection()->headObject([
  102. 'Bucket' => $this->bucket,
  103. 'Key' => $key
  104. ])->toArray();
  105. } catch (S3Exception $e) {
  106. if ($e->getStatusCode() >= 500) {
  107. throw $e;
  108. }
  109. $this->objectCache[$key] = false;
  110. }
  111. }
  112. if (is_array($this->objectCache[$key]) && !isset($this->objectCache[$key]["Key"])) {
  113. /** @psalm-suppress InvalidArgument Psalm doesn't understand nested arrays well */
  114. $this->objectCache[$key]["Key"] = $key;
  115. }
  116. return $this->objectCache[$key];
  117. }
  118. /**
  119. * Return true if directory exists
  120. *
  121. * There are no folders in s3. A folder like structure could be archived
  122. * by prefixing files with the folder name.
  123. *
  124. * Implementation from flysystem-aws-s3-v3:
  125. * https://github.com/thephpleague/flysystem-aws-s3-v3/blob/8241e9cc5b28f981e0d24cdaf9867f14c7498ae4/src/AwsS3Adapter.php#L670-L694
  126. *
  127. * @param $path
  128. * @return bool
  129. * @throws \Exception
  130. */
  131. private function doesDirectoryExist($path) {
  132. if ($path === '.' || $path === '') {
  133. return true;
  134. }
  135. $path = rtrim($path, '/') . '/';
  136. if (isset($this->directoryCache[$path])) {
  137. return $this->directoryCache[$path];
  138. }
  139. try {
  140. // Maybe this isn't an actual key, but a prefix.
  141. // Do a prefix listing of objects to determine.
  142. $result = $this->getConnection()->listObjectsV2([
  143. 'Bucket' => $this->bucket,
  144. 'Prefix' => $path,
  145. 'MaxKeys' => 1,
  146. ]);
  147. if (isset($result['Contents'])) {
  148. $this->directoryCache[$path] = true;
  149. return true;
  150. }
  151. // empty directories have their own object
  152. $object = $this->headObject($path);
  153. if ($object) {
  154. $this->directoryCache[$path] = true;
  155. return true;
  156. }
  157. } catch (S3Exception $e) {
  158. if ($e->getStatusCode() >= 400 && $e->getStatusCode() < 500) {
  159. $this->directoryCache[$path] = false;
  160. }
  161. throw $e;
  162. }
  163. $this->directoryCache[$path] = false;
  164. return false;
  165. }
  166. /**
  167. * Remove a file or folder
  168. *
  169. * @param string $path
  170. * @return bool
  171. */
  172. protected function remove($path) {
  173. // remember fileType to reduce http calls
  174. $fileType = $this->filetype($path);
  175. if ($fileType === 'dir') {
  176. return $this->rmdir($path);
  177. } elseif ($fileType === 'file') {
  178. return $this->unlink($path);
  179. } else {
  180. return false;
  181. }
  182. }
  183. public function mkdir($path) {
  184. $path = $this->normalizePath($path);
  185. if ($this->is_dir($path)) {
  186. return false;
  187. }
  188. try {
  189. $this->getConnection()->putObject([
  190. 'Bucket' => $this->bucket,
  191. 'Key' => $path . '/',
  192. 'Body' => '',
  193. 'ContentType' => FileInfo::MIMETYPE_FOLDER
  194. ]);
  195. $this->testTimeout();
  196. } catch (S3Exception $e) {
  197. $this->logger->error($e->getMessage(), [
  198. 'app' => 'files_external',
  199. 'exception' => $e,
  200. ]);
  201. return false;
  202. }
  203. $this->invalidateCache($path);
  204. return true;
  205. }
  206. public function file_exists($path) {
  207. return $this->filetype($path) !== false;
  208. }
  209. public function rmdir($path) {
  210. $path = $this->normalizePath($path);
  211. if ($this->isRoot($path)) {
  212. return $this->clearBucket();
  213. }
  214. if (!$this->file_exists($path)) {
  215. return false;
  216. }
  217. $this->invalidateCache($path);
  218. return $this->batchDelete($path);
  219. }
  220. protected function clearBucket() {
  221. $this->clearCache();
  222. return $this->batchDelete();
  223. }
  224. private function batchDelete($path = null) {
  225. // TODO explore using https://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.BatchDelete.html
  226. $params = [
  227. 'Bucket' => $this->bucket
  228. ];
  229. if ($path !== null) {
  230. $params['Prefix'] = $path . '/';
  231. }
  232. try {
  233. $connection = $this->getConnection();
  234. // Since there are no real directories on S3, we need
  235. // to delete all objects prefixed with the path.
  236. do {
  237. // instead of the iterator, manually loop over the list ...
  238. $objects = $connection->listObjects($params);
  239. // ... so we can delete the files in batches
  240. if (isset($objects['Contents'])) {
  241. $connection->deleteObjects([
  242. 'Bucket' => $this->bucket,
  243. 'Delete' => [
  244. 'Objects' => $objects['Contents']
  245. ]
  246. ]);
  247. $this->testTimeout();
  248. }
  249. // we reached the end when the list is no longer truncated
  250. } while ($objects['IsTruncated']);
  251. if ($path !== '' && $path !== null) {
  252. $this->deleteObject($path);
  253. }
  254. } catch (S3Exception $e) {
  255. $this->logger->error($e->getMessage(), [
  256. 'app' => 'files_external',
  257. 'exception' => $e,
  258. ]);
  259. return false;
  260. }
  261. return true;
  262. }
  263. public function opendir($path) {
  264. try {
  265. $content = iterator_to_array($this->getDirectoryContent($path));
  266. return IteratorDirectory::wrap(array_map(function (array $item) {
  267. return $item['name'];
  268. }, $content));
  269. } catch (S3Exception $e) {
  270. return false;
  271. }
  272. }
  273. public function stat($path) {
  274. $path = $this->normalizePath($path);
  275. if ($this->is_dir($path)) {
  276. $stat = $this->getDirectoryMetaData($path);
  277. } else {
  278. $object = $this->headObject($path);
  279. if ($object === false) {
  280. return false;
  281. }
  282. $stat = $this->objectToMetaData($object);
  283. }
  284. $stat['atime'] = time();
  285. return $stat;
  286. }
  287. /**
  288. * Return content length for object
  289. *
  290. * When the information is already present (e.g. opendir has been called before)
  291. * this value is return. Otherwise a headObject is emitted.
  292. *
  293. * @param $path
  294. * @return int|mixed
  295. */
  296. private function getContentLength($path) {
  297. if (isset($this->filesCache[$path])) {
  298. return (int)$this->filesCache[$path]['ContentLength'];
  299. }
  300. $result = $this->headObject($path);
  301. if (isset($result['ContentLength'])) {
  302. return (int)$result['ContentLength'];
  303. }
  304. return 0;
  305. }
  306. /**
  307. * Return last modified for object
  308. *
  309. * When the information is already present (e.g. opendir has been called before)
  310. * this value is return. Otherwise a headObject is emitted.
  311. *
  312. * @param $path
  313. * @return mixed|string
  314. */
  315. private function getLastModified($path) {
  316. if (isset($this->filesCache[$path])) {
  317. return $this->filesCache[$path]['LastModified'];
  318. }
  319. $result = $this->headObject($path);
  320. if (isset($result['LastModified'])) {
  321. return $result['LastModified'];
  322. }
  323. return 'now';
  324. }
  325. public function is_dir($path) {
  326. $path = $this->normalizePath($path);
  327. if (isset($this->filesCache[$path])) {
  328. return false;
  329. }
  330. try {
  331. return $this->doesDirectoryExist($path);
  332. } catch (S3Exception $e) {
  333. $this->logger->error($e->getMessage(), [
  334. 'app' => 'files_external',
  335. 'exception' => $e,
  336. ]);
  337. return false;
  338. }
  339. }
  340. public function filetype($path) {
  341. $path = $this->normalizePath($path);
  342. if ($this->isRoot($path)) {
  343. return 'dir';
  344. }
  345. try {
  346. if (isset($this->directoryCache[$path]) && $this->directoryCache[$path]) {
  347. return 'dir';
  348. }
  349. if (isset($this->filesCache[$path]) || $this->headObject($path)) {
  350. return 'file';
  351. }
  352. if ($this->doesDirectoryExist($path)) {
  353. return 'dir';
  354. }
  355. } catch (S3Exception $e) {
  356. $this->logger->error($e->getMessage(), [
  357. 'app' => 'files_external',
  358. 'exception' => $e,
  359. ]);
  360. return false;
  361. }
  362. return false;
  363. }
  364. public function getPermissions($path) {
  365. $type = $this->filetype($path);
  366. if (!$type) {
  367. return 0;
  368. }
  369. return $type === 'dir' ? Constants::PERMISSION_ALL : Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE;
  370. }
  371. public function unlink($path) {
  372. $path = $this->normalizePath($path);
  373. if ($this->is_dir($path)) {
  374. return $this->rmdir($path);
  375. }
  376. try {
  377. $this->deleteObject($path);
  378. $this->invalidateCache($path);
  379. } catch (S3Exception $e) {
  380. $this->logger->error($e->getMessage(), [
  381. 'app' => 'files_external',
  382. 'exception' => $e,
  383. ]);
  384. return false;
  385. }
  386. return true;
  387. }
  388. public function fopen($path, $mode) {
  389. $path = $this->normalizePath($path);
  390. switch ($mode) {
  391. case 'r':
  392. case 'rb':
  393. // Don't try to fetch empty files
  394. $stat = $this->stat($path);
  395. if (is_array($stat) && isset($stat['size']) && $stat['size'] === 0) {
  396. return fopen('php://memory', $mode);
  397. }
  398. try {
  399. return $this->readObject($path);
  400. } catch (\Exception $e) {
  401. $this->logger->error($e->getMessage(), [
  402. 'app' => 'files_external',
  403. 'exception' => $e,
  404. ]);
  405. return false;
  406. }
  407. case 'w':
  408. case 'wb':
  409. $tmpFile = \OC::$server->getTempManager()->getTemporaryFile();
  410. $handle = fopen($tmpFile, 'w');
  411. return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile) {
  412. $this->writeBack($tmpFile, $path);
  413. });
  414. case 'a':
  415. case 'ab':
  416. case 'r+':
  417. case 'w+':
  418. case 'wb+':
  419. case 'a+':
  420. case 'x':
  421. case 'x+':
  422. case 'c':
  423. case 'c+':
  424. if (strrpos($path, '.') !== false) {
  425. $ext = substr($path, strrpos($path, '.'));
  426. } else {
  427. $ext = '';
  428. }
  429. $tmpFile = \OC::$server->getTempManager()->getTemporaryFile($ext);
  430. if ($this->file_exists($path)) {
  431. $source = $this->readObject($path);
  432. file_put_contents($tmpFile, $source);
  433. }
  434. $handle = fopen($tmpFile, $mode);
  435. return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile) {
  436. $this->writeBack($tmpFile, $path);
  437. });
  438. }
  439. return false;
  440. }
  441. public function touch($path, $mtime = null) {
  442. if (is_null($mtime)) {
  443. $mtime = time();
  444. }
  445. $metadata = [
  446. 'lastmodified' => gmdate(\DateTime::RFC1123, $mtime)
  447. ];
  448. try {
  449. if ($this->file_exists($path)) {
  450. return false;
  451. }
  452. $mimeType = $this->mimeDetector->detectPath($path);
  453. $this->getConnection()->putObject([
  454. 'Bucket' => $this->bucket,
  455. 'Key' => $this->cleanKey($path),
  456. 'Metadata' => $metadata,
  457. 'Body' => '',
  458. 'ContentType' => $mimeType,
  459. 'MetadataDirective' => 'REPLACE',
  460. ]);
  461. $this->testTimeout();
  462. } catch (S3Exception $e) {
  463. $this->logger->error($e->getMessage(), [
  464. 'app' => 'files_external',
  465. 'exception' => $e,
  466. ]);
  467. return false;
  468. }
  469. $this->invalidateCache($path);
  470. return true;
  471. }
  472. public function copy($source, $target, $isFile = null) {
  473. $source = $this->normalizePath($source);
  474. $target = $this->normalizePath($target);
  475. if ($isFile === true || $this->is_file($source)) {
  476. try {
  477. $this->copyObject($source, $target, [
  478. 'StorageClass' => $this->storageClass,
  479. ]);
  480. $this->testTimeout();
  481. } catch (S3Exception $e) {
  482. $this->logger->error($e->getMessage(), [
  483. 'app' => 'files_external',
  484. 'exception' => $e,
  485. ]);
  486. return false;
  487. }
  488. } else {
  489. $this->remove($target);
  490. try {
  491. $this->mkdir($target);
  492. $this->testTimeout();
  493. } catch (S3Exception $e) {
  494. $this->logger->error($e->getMessage(), [
  495. 'app' => 'files_external',
  496. 'exception' => $e,
  497. ]);
  498. return false;
  499. }
  500. foreach ($this->getDirectoryContent($source) as $item) {
  501. $childSource = $source . '/' . $item['name'];
  502. $childTarget = $target . '/' . $item['name'];
  503. $this->copy($childSource, $childTarget, $item['mimetype'] !== FileInfo::MIMETYPE_FOLDER);
  504. }
  505. }
  506. $this->invalidateCache($target);
  507. return true;
  508. }
  509. public function rename($source, $target) {
  510. $source = $this->normalizePath($source);
  511. $target = $this->normalizePath($target);
  512. if ($this->is_file($source)) {
  513. if ($this->copy($source, $target) === false) {
  514. return false;
  515. }
  516. if ($this->unlink($source) === false) {
  517. $this->unlink($target);
  518. return false;
  519. }
  520. } else {
  521. if ($this->copy($source, $target) === false) {
  522. return false;
  523. }
  524. if ($this->rmdir($source) === false) {
  525. $this->rmdir($target);
  526. return false;
  527. }
  528. }
  529. return true;
  530. }
  531. public function test() {
  532. $this->getConnection()->headBucket([
  533. 'Bucket' => $this->bucket
  534. ]);
  535. return true;
  536. }
  537. public function getId() {
  538. return $this->id;
  539. }
  540. public function writeBack($tmpFile, $path) {
  541. try {
  542. $source = fopen($tmpFile, 'r');
  543. $this->writeObject($path, $source, $this->mimeDetector->detectPath($path));
  544. $this->invalidateCache($path);
  545. unlink($tmpFile);
  546. return true;
  547. } catch (S3Exception $e) {
  548. $this->logger->error($e->getMessage(), [
  549. 'app' => 'files_external',
  550. 'exception' => $e,
  551. ]);
  552. return false;
  553. }
  554. }
  555. /**
  556. * check if curl is installed
  557. */
  558. public static function checkDependencies() {
  559. return true;
  560. }
  561. public function getDirectoryContent($directory): \Traversable {
  562. $path = $this->normalizePath($directory);
  563. if ($this->isRoot($path)) {
  564. $path = '';
  565. } else {
  566. $path .= '/';
  567. }
  568. $results = $this->getConnection()->getPaginator('ListObjectsV2', [
  569. 'Bucket' => $this->bucket,
  570. 'Delimiter' => '/',
  571. 'Prefix' => $path,
  572. ]);
  573. foreach ($results as $result) {
  574. // sub folders
  575. if (is_array($result['CommonPrefixes'])) {
  576. foreach ($result['CommonPrefixes'] as $prefix) {
  577. $dir = $this->getDirectoryMetaData($prefix['Prefix']);
  578. if ($dir) {
  579. yield $dir;
  580. }
  581. }
  582. }
  583. if (is_array($result['Contents'])) {
  584. foreach ($result['Contents'] as $object) {
  585. $this->objectCache[$object['Key']] = $object;
  586. if ($object['Key'] !== $path) {
  587. yield $this->objectToMetaData($object);
  588. }
  589. }
  590. }
  591. }
  592. }
  593. private function objectToMetaData(array $object): array {
  594. return [
  595. 'name' => basename($object['Key']),
  596. 'mimetype' => $this->mimeDetector->detectPath($object['Key']),
  597. 'mtime' => strtotime($object['LastModified']),
  598. 'storage_mtime' => strtotime($object['LastModified']),
  599. 'etag' => trim($object['ETag'], '"'),
  600. 'permissions' => Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE,
  601. 'size' => (int)($object['Size'] ?? $object['ContentLength']),
  602. ];
  603. }
  604. private function getDirectoryMetaData(string $path): ?array {
  605. $path = trim($path, '/');
  606. // when versioning is enabled, delete markers are returned as part of CommonPrefixes
  607. // resulting in "ghost" folders, verify that each folder actually exists
  608. if ($this->versioningEnabled() && !$this->doesDirectoryExist($path)) {
  609. return null;
  610. }
  611. $cacheEntry = $this->getCache()->get($path);
  612. if ($cacheEntry instanceof CacheEntry) {
  613. return $cacheEntry->getData();
  614. } else {
  615. return [
  616. 'name' => basename($path),
  617. 'mimetype' => FileInfo::MIMETYPE_FOLDER,
  618. 'mtime' => time(),
  619. 'storage_mtime' => time(),
  620. 'etag' => uniqid(),
  621. 'permissions' => Constants::PERMISSION_ALL,
  622. 'size' => -1,
  623. ];
  624. }
  625. }
  626. public function versioningEnabled(): bool {
  627. if ($this->versioningEnabled === null) {
  628. $cached = $this->memCache->get('versioning-enabled::' . $this->getBucket());
  629. if ($cached === null) {
  630. $this->versioningEnabled = $this->getVersioningStatusFromBucket();
  631. $this->memCache->set('versioning-enabled::' . $this->getBucket(), $this->versioningEnabled, 60);
  632. } else {
  633. $this->versioningEnabled = $cached;
  634. }
  635. }
  636. return $this->versioningEnabled;
  637. }
  638. protected function getVersioningStatusFromBucket(): bool {
  639. try {
  640. $result = $this->getConnection()->getBucketVersioning(['Bucket' => $this->getBucket()]);
  641. return $result->get('Status') === 'Enabled';
  642. } catch (S3Exception $s3Exception) {
  643. // This is needed for compatibility with Storj gateway which does not support versioning yet
  644. if ($s3Exception->getAwsErrorCode() === 'NotImplemented' || $s3Exception->getAwsErrorCode() === 'AccessDenied') {
  645. return false;
  646. }
  647. throw $s3Exception;
  648. }
  649. }
  650. public function hasUpdated($path, $time) {
  651. // for files we can get the proper mtime
  652. if ($path !== '' && $object = $this->headObject($path)) {
  653. $stat = $this->objectToMetaData($object);
  654. return $stat['mtime'] > $time;
  655. } else {
  656. // for directories, the only real option we have is to do a prefix listing and iterate over all objects
  657. // however, since this is just as expensive as just re-scanning the directory, we can simply return true
  658. // and have the scanner figure out if anything has actually changed
  659. return true;
  660. }
  661. }
  662. }