diff --git a/admin/test/scripts/test_lava_db_source_path.py b/admin/test/scripts/test_lava_db_source_path.py new file mode 100644 index 000000000..e10d2a636 --- /dev/null +++ b/admin/test/scripts/test_lava_db_source_path.py @@ -0,0 +1,148 @@ +"""Pin what an artifact that declares no search paths records as its source path. + +An artifact whose `paths` is None searches for nothing. The main script hands it +'/_lava_artifacts.db' as its only files_found entry, because it reads +the rows an earlier artifact wrote into the LAVA database rather than any file in the +extraction. All 35 such artifacts in iLEAPP are in logarchive.py, and each returns that +path back as its source_path. + +`Context.get_relative_path` used to strip the data folder and nothing else. The LAVA +database sits in the report folder, one level above the data folder, so the prefix never +matched and the path was returned unchanged. The examiner's own output directory then +reached the artifact page's "located at" line and the LAVA manifest's `source_path`, +which travels with the report. + +Measured on a real run before the fix: 4 of 5 manifest entries and all 3 artifact pages +carried the absolute path. + +These tests drive `Context.get_relative_path` directly with both folders set the way a +run sets them, so they fail on the unfixed function rather than on a recorded baseline. +""" +import os +import pathlib +import sys +import unittest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT)) + +from scripts.context import Context # pylint: disable=wrong-import-position + +# The layout OutputParameters builds: the data folder is inside the report folder. +REPORT = os.path.join('/Users', 'examiner', 'Cases', 'iLEAPP_Output_2026') +DATA = os.path.join(REPORT, 'data') +LAVA_DB = os.path.join(REPORT, '_lava_artifacts.db') + + +class RelativePathTestCase(unittest.TestCase): + """Both folders set the way a run sets them.""" + + def setUp(self): + Context.clear() + Context._data_folder = DATA # pylint: disable=protected-access + Context._output_folder_base = REPORT # pylint: disable=protected-access + + def tearDown(self): + Context._data_folder = None # pylint: disable=protected-access + Context._output_folder_base = None # pylint: disable=protected-access + Context.clear() + + +class TestTheLavaDatabaseIsReportedByName(RelativePathTestCase): + """The file the runner hands a paths-None artifact.""" + + def test_the_lava_database_loses_the_examiners_report_folder(self): + self.assertEqual(Context.get_relative_path(LAVA_DB), '_lava_artifacts.db') + + def test_no_part_of_the_report_folder_survives(self): + got = Context.get_relative_path(LAVA_DB) + self.assertNotIn(REPORT, got) + self.assertFalse(os.path.isabs(got), f'still absolute: {got!r}') + + def test_another_file_the_run_writes_keeps_its_place_inside_the_report(self): + page = os.path.join(REPORT, '_HTML', 'logarchive_wifi_status.html') + self.assertEqual(Context.get_relative_path(page), + os.path.join('_HTML', 'logarchive_wifi_status.html')) + + +class TestTheDataFolderStillWins(RelativePathTestCase): + """The data folder is inside the report folder, so it has to be stripped first. + + Stripping the report folder first would leave 'data/' on the front of every staged + evidence path, which is the regression this ordering exists to prevent. + """ + + def test_a_staged_evidence_file_is_reported_without_the_data_prefix(self): + staged = os.path.join(DATA, 'private', 'var', 'mobile', 'Library', 'x.db') + self.assertEqual(Context.get_relative_path(staged), + os.path.join('private', 'var', 'mobile', 'Library', 'x.db')) + + def test_a_staged_path_does_not_come_back_prefixed_with_data(self): + staged = os.path.join(DATA, 'private', 'var', 'x.db') + got = Context.get_relative_path(staged) + self.assertFalse(got.startswith('data'), f'data prefix survived: {got!r}') + + def test_several_staged_paths_in_one_string_are_all_reduced(self): + joined = '\n'.join([os.path.join(DATA, 'a', 'one.db'), + os.path.join(DATA, 'b', 'two.db')]) + self.assertEqual(Context.get_relative_path(joined), + '\n'.join([os.path.join('a', 'one.db'), + os.path.join('b', 'two.db')])) + + +class TestNothingElseChanged(RelativePathTestCase): + """Values that carry neither prefix are still handed back untouched.""" + + def test_a_path_outside_both_folders_is_unchanged(self): + other = os.path.join('/Users', 'examiner', 'Desktop', 'notes.txt') + self.assertEqual(Context.get_relative_path(other), other) + + def test_an_already_relative_path_is_unchanged(self): + self.assertEqual(Context.get_relative_path('export/logarchive.json'), + 'export/logarchive.json') + + def test_an_empty_value_is_unchanged(self): + self.assertEqual(Context.get_relative_path(''), '') + self.assertIsNone(Context.get_relative_path(None)) + + +class TestWithNeitherFolderKnown(unittest.TestCase): + """The committed harness sets no folders, so the function stays a no-op there.""" + + def setUp(self): + Context.clear() + Context._data_folder = None # pylint: disable=protected-access + Context._output_folder_base = None # pylint: disable=protected-access + + def test_every_path_is_returned_unchanged(self): + self.assertEqual(Context.get_relative_path(LAVA_DB), LAVA_DB) + + def test_the_report_folder_alone_is_enough_to_reduce_the_lava_database(self): + Context._output_folder_base = REPORT # pylint: disable=protected-access + try: + self.assertEqual(Context.get_relative_path(LAVA_DB), '_lava_artifacts.db') + finally: + Context._output_folder_base = None # pylint: disable=protected-access + + +class TestTheRunPopulatesTheReportFolder(unittest.TestCase): + """set_output_params has to record the base, or the strip above never fires.""" + + def tearDown(self): + Context._output_params = None # pylint: disable=protected-access + Context._data_folder = None # pylint: disable=protected-access + Context._output_folder_base = None # pylint: disable=protected-access + + def test_both_folders_are_taken_from_the_output_parameters(self): + class FakeOutputParameters: # pylint: disable=too-few-public-methods + output_folder_base = REPORT + data_folder = DATA + + Context.set_output_params(FakeOutputParameters()) + self.assertEqual(Context._output_folder_base, REPORT) # pylint: disable=protected-access + self.assertEqual(Context._data_folder, DATA) # pylint: disable=protected-access + self.assertEqual(Context.get_relative_path(LAVA_DB), '_lava_artifacts.db') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/context.py b/scripts/context.py index 687caf3c7..106e34065 100644 --- a/scripts/context.py +++ b/scripts/context.py @@ -29,6 +29,7 @@ class Context: _files_found = [] _filename_lookup_map = {} _data_folder = None + _output_folder_base = None _metadata = {} _installed_os_version = "" # Run-level, like the output parameters: set once from the CLI or GUI and @@ -46,6 +47,8 @@ def set_output_params(output_params): """ Context._output_params = output_params Context._data_folder = getattr(output_params, 'data_folder', None) + Context._output_folder_base = getattr( + output_params, 'output_folder_base', None) @staticmethod def set_data_folder(data_folder): @@ -544,27 +547,46 @@ def _normalize_source_path(source_path): @staticmethod def get_relative_path(full_path): """ - Converts a full on-disk path (from files_found) to a relative - extraction path by removing the global data_folder prefix. + Converts a full on-disk path into one that carries none of the + examiner's own filesystem layout. + + Two prefixes are stripped, in this order: + + 1. the data folder, where the seeker stages evidence, so a staged + file is reported by its path inside the extraction; + 2. the report folder, so a file the run itself writes is reported by + its path inside the report. + + The second case exists for artifacts that declare no search paths. The + main script hands those '/_lava_artifacts.db' as their + source file, because they read the rows a previous artifact wrote + rather than any file in the extraction. That path is outside the data + folder, so before this it was reported unchanged and the examiner's own + report directory reached both the "located at" line and the LAVA + manifest's source_path. + + The data folder is tried first because it sits inside the report + folder: stripping the report folder first would leave every staged + evidence path prefixed with 'data/'. Args: full_path (str): The full path to the file. Returns: - str: The relative extraction path, or the original path if - the data_folder is not available. + str: The relative path, or the original path if neither prefix is + known or present. """ - if not full_path or not Context._data_folder: + if not full_path: return full_path - if Context._data_folder in full_path: - # Strip the base path everywhere it appears, including inside path - # strings concatenated with arbitrary separators (', ', '; ', ...) - base = Context._data_folder - return (full_path.replace(base + '/', '') - .replace(base + '\\', '') - .replace(base, '') - .lstrip('/\\')) + for base in (Context._data_folder, Context._output_folder_base): + if base and base in full_path: + # Strip the base path everywhere it appears, including inside path + # strings concatenated with arbitrary separators (', ', '; ', ...) + return (full_path.replace(base + '/', '') + .replace(base + '\\', '') + .replace(base, '') + .lstrip('/\\')) return full_path