From c92ebe4de44b9908db38e677ac69a4be8cc03e76 Mon Sep 17 00:00:00 2001 From: Brigs Date: Fri, 18 Sep 2026 22:13:54 -0400 Subject: [PATCH] Record evidence-relative source paths for directory and single-file inputs FileSeekerDir recorded the absolute path on the examiner's machine in file_infos and FileSeekerFile its absolute input path, while the zip, tar, raw image and iVa seekers record where the file sits in the evidence. The value reaches the LAVA _file_path_list, every media item's source_path and any artifact that reads file_infos, so the same column meant two different things depending on the input type, and a directory input published the examiner's folder layout. Both now record the evidence form: the path relative to the input directory with forward slashes, or the file name for a single-file input. The case-variant tag already hashed that spelling, so tagged copies keep their names. The directory seeker also built each staged path by slicing the input path off the front of the match and dropping one more character. An input ending in a separator lost the first letter of every staged path, and an input of '.' lost every dot. os.path.relpath handles both, and the extended-length prefix the entry points add on Windows. The new test runs the real seekers against a small tree with the input given as an absolute path, with a trailing separator, relative, and as '.'. On Windows it also tries the extended-length prefix, and the Windows smoke job now runs it. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/windows_smoke.yml | 6 ++ admin/test/scripts/test_seeker_source_path.py | 101 ++++++++++++++++++ scripts/search_files.py | 26 +++-- 3 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 admin/test/scripts/test_seeker_source_path.py diff --git a/.github/workflows/windows_smoke.yml b/.github/workflows/windows_smoke.yml index a30f7bd7..4678c141 100644 --- a/.github/workflows/windows_smoke.yml +++ b/.github/workflows/windows_smoke.yml @@ -41,3 +41,9 @@ jobs: - name: Open SQLite on a path longer than 260 characters run: python admin/test/scripts/test_sqlite_longpath_uri.py + + # The entry points hand a directory input to the seeker with the extended-length + # prefix on Windows, so the recorded evidence path and the staged path are + # checked here with that prefix as well as without it. + - name: Stage from a directory input with and without the extended-length prefix + run: python admin/test/scripts/test_seeker_source_path.py diff --git a/admin/test/scripts/test_seeker_source_path.py b/admin/test/scripts/test_seeker_source_path.py new file mode 100644 index 00000000..3a29c320 --- /dev/null +++ b/admin/test/scripts/test_seeker_source_path.py @@ -0,0 +1,101 @@ +"""Pin what the directory and single-file seekers record in file_infos. + +The zip, tar, raw image and iTunes seekers record where a file sits in the +evidence. The directory seeker used to record the absolute path on the +examiner's machine and the single-file seeker its absolute input path, so the +LAVA file list, every media source path and any artifact that reads file_infos +carried the examiner's folder layout whenever the input was a directory or a +single file (iLEAPP issue #2057). + +The directory seeker also built each staged path by slicing the input path off +the front of the match and dropping one more character. An input ending in a +separator lost the first letter of every staged path ('rivate/var/...') and an +input of '.' removed every dot from every staged path ('Cachedb'). + +These tests build a small evidence tree and run the real seekers against it, +with the input given as an absolute path, that path with a trailing separator, +a relative path and '.'. On Windows the absolute form is also tried with the +extended-length prefix the entry points add for a directory input. +""" +import os +import pathlib +import shutil +import sys +import tempfile +import unittest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT)) + +from scripts.search_files import FileSeekerDir, FileSeekerFile # pylint: disable=wrong-import-position + +REL = 'private/var/mobile/Library/Preferences/com.apple.MobileSMS.plist' +CONTENT = b'stand-in plist bytes' +PATTERN = '*/mobile/Library/Preferences/com.apple.MobileSMS.plist' + + +class TestDirectoryAndSingleFileSourcePaths(unittest.TestCase): + """The recorded source path and the staged path, per input form.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix='leapp_seeker_src_') + self.root = os.path.join(self.tmp, 'extraction') + self.evidence_file = os.path.join(self.root, *REL.split('/')) + os.makedirs(os.path.dirname(self.evidence_file)) + with open(self.evidence_file, 'wb') as fout: + fout.write(CONTENT) + self.cwd = os.getcwd() + + def tearDown(self): + os.chdir(self.cwd) + shutil.rmtree(self.tmp, ignore_errors=True) + + def _data_folder(self, label): + folder = os.path.join(self.tmp, 'data_' + label) + os.makedirs(folder) + return folder + + def _check_directory_input(self, label, directory): + data_folder = self._data_folder(label) + seeker = FileSeekerDir(directory, data_folder) + found = seeker.search(PATTERN) + self.assertEqual(len(found), 1, (label, found)) + staged = found[0] + self.assertEqual(seeker.file_infos[staged].source_path, REL, label) + self.assertEqual(os.path.relpath(staged, data_folder).replace(os.sep, '/'), REL, label) + with open(staged, 'rb') as fin: + self.assertEqual(fin.read(), CONTENT, label) + + def test_absolute_directory_input(self): + self._check_directory_input('absolute', self.root) + + def test_directory_input_with_a_trailing_separator(self): + self._check_directory_input('trailing', self.root + os.sep) + + def test_relative_directory_input(self): + os.chdir(self.tmp) + self._check_directory_input('relative', 'extraction') + + def test_dot_as_the_directory_input(self): + os.chdir(self.root) + self._check_directory_input('dot', '.') + + @unittest.skipUnless(os.name == 'nt', 'the extended-length prefix is a Windows path form') + def test_extended_length_prefix_on_windows(self): + prefixed = '\\\\?\\' + self.root.replace('/', '\\') + self._check_directory_input('prefixed', prefixed) + self._check_directory_input('prefixed_trailing', prefixed + '\\') + + def test_single_file_input_records_the_file_name(self): + data_folder = self._data_folder('file') + seeker = FileSeekerFile(self.evidence_file, data_folder) + found = seeker.search(PATTERN) + self.assertEqual(len(found), 1, found) + self.assertEqual(seeker.file_infos[found[0]].source_path, 'com.apple.MobileSMS.plist') + self.assertEqual(os.path.basename(found[0]), 'com.apple.MobileSMS.plist') + with open(found[0], 'rb') as fin: + self.assertEqual(fin.read(), CONTENT) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/search_files.py b/scripts/search_files.py index c28f7f45..12849989 100755 --- a/scripts/search_files.py +++ b/scripts/search_files.py @@ -158,7 +158,10 @@ class FileInfo: """ A class to store file metadata information. Attributes: - source_path (str): The full path to the source file. + source_path (str): Where the file sits in the evidence: an archive + member name as stored, a path relative to the input directory, a + volume path inside a raw image, or the file name for a single-file + input. Never a path on the examiner's machine. creation_date (datetime): The date and time when the file was created. modification_date (datetime): The date and time when the file was last modified. """ @@ -250,23 +253,28 @@ def search(self, filepattern, return_on_first_hit=False, force=False): root = normcase("root/") for item in self._all_files: if pat(root + normcase(item)) is not None: - item_rel_path = item.replace(self.directory, '') - data_path = os.path.join(self.data_folder, item_rel_path[1:]) - if is_platform_windows(): - data_path = data_path.replace('/', '\\') + # Relative to the input root, so the staged tree and the recorded + # source path do not depend on where the extraction sits on the + # examiner's machine, on a trailing separator in the input path or + # on the \\?\ prefix the entry points add on Windows. The former + # prefix slice dropped the first character of every staged path + # after a trailing separator, and every dot when the input was '.'. + item_rel_path = os.path.relpath(item, self.directory) + source_path = item_rel_path.replace('\\', '/') + data_path = os.path.join(self.data_folder, item_rel_path) if item not in self.copied or force: try: if os.path.isdir(item): pass elif os.path.isfile(item): data_path = self._unique_data_path( - data_path, item, hash_source=item_rel_path) + data_path, item, hash_source=source_path) os.makedirs(os.path.dirname(data_path), exist_ok=True) copy2(item, data_path) self.copied[item] = data_path creation_date = Path(item).stat().st_ctime modification_date = Path(item).stat().st_mtime - file_info = FileInfo(item, creation_date, modification_date) + file_info = FileInfo(source_path, creation_date, modification_date) self.file_infos[data_path] = file_info else: logfunc(f"INFO: Item '{item}' is neither a file nor a directory " @@ -729,7 +737,9 @@ def search(self, filepattern, return_on_first_hit=False, force=False): copy2(self.single_file_abs_path, dest_data_path) self.copied[self.single_file_abs_path] = dest_data_path s = Path(self.single_file_abs_path).stat() - file_info_obj = FileInfo(self.single_file_abs_path, s.st_ctime, s.st_mtime) + # The file name is all that places this input in the evidence; + # the directory it came from is the examiner's, not the device's. + file_info_obj = FileInfo(self.single_file_basename, s.st_ctime, s.st_mtime) self.file_infos[dest_data_path] = file_info_obj found_data_paths.append(dest_data_path) # logfunc(f"FileSeekerFile: Matched and copied. Dest: {dest_data_path}")