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}")