From 0eaf3ce605a883b6dc301e3314c497210234814a Mon Sep 17 00:00:00 2001 From: Bruno Moreira Date: Thu, 16 Jul 2026 19:31:01 -0400 Subject: [PATCH] fix: Make get_dir_file_info() call get_file_info() for each file. Fix #47. --- system/helpers/file_helper.php | 14 ++- .../codeigniter/helpers/file_helper_test.php | 92 +++++++++++++++++++ user_guide_src/source/helpers/file_helper.rst | 4 + .../source/installation/upgrade_320.rst | 25 +++++ 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/system/helpers/file_helper.php b/system/helpers/file_helper.php index dc5ee6b5a5c..fae5bf229f4 100644 --- a/system/helpers/file_helper.php +++ b/system/helpers/file_helper.php @@ -219,6 +219,8 @@ function get_filenames($source_dir, $include_path = FALSE, $_recursion = FALSE) * * Any sub-folders contained within the specified path are read as well. * + * Keys are the file paths relative to $source_dir. + * * @param string path to source * @param bool Look only at the top level directory specified? * @param bool internal variable to determine recursion status - do not use in calls @@ -227,6 +229,7 @@ function get_filenames($source_dir, $include_path = FALSE, $_recursion = FALSE) function get_dir_file_info($source_dir, $top_level_only = TRUE, $_recursion = FALSE) { static $_filedata = array(); + static $_root_dir = ''; $relative_path = $source_dir; if ($fp = @opendir($source_dir)) @@ -236,6 +239,7 @@ function get_dir_file_info($source_dir, $top_level_only = TRUE, $_recursion = FA { $_filedata = array(); $source_dir = rtrim(realpath($source_dir), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + $_root_dir = $source_dir; } // Used to be foreach (scandir($source_dir, 1) as $file), but scandir() is simply not as fast @@ -247,9 +251,13 @@ function get_dir_file_info($source_dir, $top_level_only = TRUE, $_recursion = FA } elseif ($file[0] !== '.') { - $filedata = get_dir_file_info($source_dir.$file); - $filedata['relative_path'] = $relative_path; - $_filedata[] = $filedata; + $filedata = get_file_info($source_dir.$file); + + if (is_array($filedata)) + { + $filedata['relative_path'] = $relative_path; + $_filedata[substr($source_dir.$file, strlen($_root_dir))] = $filedata; + } } } diff --git a/tests/codeigniter/helpers/file_helper_test.php b/tests/codeigniter/helpers/file_helper_test.php index 8d7f8e1eba9..9b384deb7d6 100644 --- a/tests/codeigniter/helpers/file_helper_test.php +++ b/tests/codeigniter/helpers/file_helper_test.php @@ -2,6 +2,8 @@ class File_helper_Test extends CI_TestCase { + private $_real_dir = NULL; + public function set_up() { $this->helper('file'); @@ -11,6 +13,17 @@ public function set_up() // -------------------------------------------------------------------- + public function tear_down() + { + if ($this->_real_dir !== NULL) + { + $this->_delete_real_dir($this->_real_dir); + $this->_real_dir = NULL; + } + } + + // -------------------------------------------------------------------- + public function test_octal_permissions() { $content = 'Jack and Jill went up the mountain to fight a billy goat.'; @@ -116,6 +129,47 @@ private function _test_get_file_info($vals) // -------------------------------------------------------------------- + public function test_get_dir_file_info() + { + $this->assertFalse(get_dir_file_info('i_am_not_a_directory')); + + $dir = $this->_create_real_dir(); + mkdir($dir.'sub'); + file_put_contents($dir.'top.txt', 'a'); + file_put_contents($dir.'shared.txt', 'bb'); + file_put_contents($dir.'sub'.DIRECTORY_SEPARATOR.'shared.txt', 'ccc'); + + // Default is top-level only: sub/ gets an entry of its own, its contents don't + $info = get_dir_file_info($dir); + + $this->assertEquals(array('shared.txt', 'sub', 'top.txt'), $this->_sorted_keys($info)); + $this->assertEquals('shared.txt', $info['shared.txt']['name']); + $this->assertEquals(2, $info['shared.txt']['size']); + $this->assertEquals($dir, $info['shared.txt']['relative_path']); + } + + // -------------------------------------------------------------------- + + public function test_get_dir_file_info_recursive() + { + $dir = $this->_create_real_dir(); + mkdir($dir.'sub'); + file_put_contents($dir.'top.txt', 'a'); + file_put_contents($dir.'shared.txt', 'bb'); + file_put_contents($dir.'sub'.DIRECTORY_SEPARATOR.'shared.txt', 'ccc'); + + $info = get_dir_file_info($dir, FALSE); + $nested = 'sub'.DIRECTORY_SEPARATOR.'shared.txt'; + + // Identically named files in different folders must not overwrite each other + $this->assertEquals(array('shared.txt', $nested, 'top.txt'), $this->_sorted_keys($info)); + $this->assertEquals(2, $info['shared.txt']['size']); + $this->assertEquals(3, $info[$nested]['size']); + $this->assertEquals('shared.txt', $info[$nested]['name']); + } + + // -------------------------------------------------------------------- + public function test_write_file() { $content = 'Jack and Jill went up the mountain to fight a billy goat.'; @@ -128,4 +182,42 @@ public function test_write_file() $this->assertTrue(write_file(vfsStream::url('write.txt'), $content)); } + // -------------------------------------------------------------------- + + /** + * get_dir_file_info() resolves its source path with realpath(), which returns + * FALSE for vfsStream URLs, so these tests can't use vfsStream like the rest. + */ + private function _create_real_dir() + { + $this->_real_dir = sys_get_temp_dir().DIRECTORY_SEPARATOR.uniqid('ci_file_helper_', TRUE).DIRECTORY_SEPARATOR; + mkdir($this->_real_dir); + + return $this->_real_dir; + } + + private function _delete_real_dir($dir) + { + foreach (scandir($dir) as $file) + { + if ($file === '.' OR $file === '..') + { + continue; + } + + $path = rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$file; + is_dir($path) ? $this->_delete_real_dir($path) : unlink($path); + } + + rmdir($dir); + } + + private function _sorted_keys($info) + { + $keys = array_keys($info); + sort($keys); + + return $keys; + } + } diff --git a/user_guide_src/source/helpers/file_helper.rst b/user_guide_src/source/helpers/file_helper.rst index e96546a11a1..f746c711d7a 100644 --- a/user_guide_src/source/helpers/file_helper.rst +++ b/user_guide_src/source/helpers/file_helper.rst @@ -113,6 +113,10 @@ The following functions are available: if forced by sending the second parameter to FALSE, as this can be an intensive operation. + Array keys are the file paths relative to ``$source_dir``. For a top-level read that + is the filename; a recursive read keys files in sub-folders by their sub-path, e.g. + ``blog/post.php`` (``blog\post.php`` on Windows). + Example:: $models_info = get_dir_file_info(APPPATH.'models/'); diff --git a/user_guide_src/source/installation/upgrade_320.rst b/user_guide_src/source/installation/upgrade_320.rst index 0f08eb912ee..44a4ffb03fb 100644 --- a/user_guide_src/source/installation/upgrade_320.rst +++ b/user_guide_src/source/installation/upgrade_320.rst @@ -238,3 +238,28 @@ if you haven't copied the new one over. The ``$config['log_file_extension']`` option still works as a fallback, but ``$config['log_filename']`` takes precedence when set. + +Step 15: Check recursive usage of get_dir_file_info() +===================================================== + +In 3.1.x, the :doc:`File Helper <../helpers/file_helper>` function +:php:func:`get_dir_file_info()` used to key its output by filename. When +reading recursively (by passing FALSE as the second parameter), two files +with the same name in different sub-folders collided, and only the last one +read was kept. + +Output is now keyed by each file's path relative to ``$source_dir``:: + + // A directory containing config.php and cache/config.php + $info = get_dir_file_info('./path/to/directory/', FALSE); + + // 3.1.x: array('config.php' => ...) + // now: array('config.php' => ..., 'cache/config.php' => ...) + +For a top-level read (the default), a file's relative path is its filename, +so those keys are unchanged. Only code that reads recursively and looks up +entries by key needs review. + +.. note:: In 3.2-dev, this function was broken and returned entries holding + only a ``relative_path`` key, so there is nothing to migrate if you are + coming from there.