From 10f739d66949e017db42ef2ac2455cf6c9db3fef Mon Sep 17 00:00:00 2001 From: Muhammad Mohid Shafiq Date: Thu, 8 Oct 2026 19:35:29 +0500 Subject: [PATCH 1/3] Keep settings history timestamps on 29 February dumpsys prints the settings change history without a year, so _resolve_timestamp() parsed the value with strptime("%m-%d ...") and only then replaced the year. strptime() parses a value without a year in 1900, which has no 29 February, so every change made on a leap day raised ValueError and lost its timestamp, dropping it from the timeline and from the alert of a dangerous setting. Parse the value together with a year instead, starting from the year the section was dumped and going back until the date exists and is not later than the dump. Eight years always reach a leap year. This also removes the DeprecationWarning Python 3.13 raises for parsing a day of month without a year (python/cpython#70647). --- src/mvt/android/artifacts/settings.py | 21 +++++++++------ tests/android/test_artifact_settings.py | 35 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index 39257c06..8237ee34 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.py @@ -255,19 +255,24 @@ class Settings(AndroidArtifact): dumpsys prints the change history without a year, so it is resolved against the time the section was dumped: the most recent matching date at or before that time. + + The year is parsed together with the value, because strptime() would + otherwise parse it in 1900, which has no 29 February. Going back eight + years always reaches a leap year. """ if section_end is None: return None - try: - partial = datetime.strptime(value, "%m-%d %H:%M:%S.%f") - timestamp = partial.replace(year=section_end.year) - if timestamp > section_end: - timestamp = partial.replace(year=section_end.year - 1) - except ValueError: - return None + for year in range(section_end.year, section_end.year - 8, -1): + try: + timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f") + except ValueError: + continue - return convert_datetime_to_iso(timestamp) + if timestamp <= section_end: + return convert_datetime_to_iso(timestamp) + + return None def _parse_history( self, line: str, section_end: Optional[datetime] diff --git a/tests/android/test_artifact_settings.py b/tests/android/test_artifact_settings.py index 2192c12b..f114cab7 100644 --- a/tests/android/test_artifact_settings.py +++ b/tests/android/test_artifact_settings.py @@ -3,6 +3,8 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +from typing import Optional + from mvt.android.artifacts.settings import Settings from ..utils import get_artifact @@ -21,6 +23,21 @@ def find(settings: Settings, name: str) -> list: return [result for result in settings.results if result["name"] == name] +def resolve_history_time(time: str, section_end: str) -> Optional[str]: + settings = Settings() + settings.parse( + "SECURE SETTINGS (user 0)\n" + "_id:240 name:accessibility_enabled pkg:android value:1\n" + "\tHistory (accessibility_enabled)\n" + f"\t\ttime:{time} mode:update oldValue:0 newValue:1 " + "package:com.example.helper\n" + "\n" + "--------- 0.019s was the duration of dumpsys settings, " + f"ending at: {section_end}\n" + ) + return settings.results[0]["history"][0]["timestamp"] + + class TestSettingsArtifact: def test_parsing(self): settings = parse_bugreport_settings() @@ -159,6 +176,24 @@ class TestSettingsArtifact: } ] + def test_leap_day_history_keeps_its_timestamp(self): + assert ( + resolve_history_time("02-29 22:41:07.980", "2024-03-05 23:14:28") + == "2024-02-29 22:41:07.980000" + ) + + def test_leap_day_history_resolved_to_the_last_leap_year(self): + # The most recent 29 February at or before the dump can be several + # years back when the dump was taken in a year without one. + assert ( + resolve_history_time("02-29 22:41:07.980", "2025-03-01 10:00:00") + == "2024-02-29 22:41:07.980000" + ) + assert ( + resolve_history_time("02-29 22:41:07.980", "2024-01-10 10:00:00") + == "2020-02-29 22:41:07.980000" + ) + def test_dangerous_setting_is_detected_with_the_changing_package(self): settings = parse_bugreport_settings() settings.check_indicators() From 1e05f438d7c952300b646372d6f894da96358e8c Mon Sep 17 00:00:00 2001 From: besendorf Date: Fri, 9 Oct 2026 03:48:37 -0700 Subject: [PATCH 2/3] Include the eighth prior year when resolving settings leap days Adjust the year range for leap year calculation to account for century years. --- src/mvt/android/artifacts/settings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index 8237ee34..91b357d6 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.py @@ -257,13 +257,14 @@ class Settings(AndroidArtifact): at or before that time. The year is parsed together with the value, because strptime() would - otherwise parse it in 1900, which has no 29 February. Going back eight - years always reaches a leap year. + otherwise parse it in 1900, which has no 29 February. The most recent + matching leap day can be eight years earlier when a century year is + not a leap year and the current year's leap day is still in the future. """ if section_end is None: return None - for year in range(section_end.year, section_end.year - 8, -1): + for year in range(section_end.year, section_end.year - 9, -1): try: timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f") except ValueError: From aac41ec8c24b8e64571018d5bf6bfa0bf1a4c9d7 Mon Sep 17 00:00:00 2001 From: besendorf Date: Fri, 9 Oct 2026 03:49:54 -0700 Subject: [PATCH 3/3] Test settings leap-day resolution across a skipped century Ensure a February 29 history entry in a January 2104 dump resolves to 2096, since 2100 is not a leap year. --- tests/android/test_artifact_settings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/android/test_artifact_settings.py b/tests/android/test_artifact_settings.py index f114cab7..239490a2 100644 --- a/tests/android/test_artifact_settings.py +++ b/tests/android/test_artifact_settings.py @@ -194,6 +194,14 @@ class TestSettingsArtifact: == "2020-02-29 22:41:07.980000" ) + def test_leap_day_history_before_a_leap_day_after_a_skipped_century(self): + # 2100 is not a leap year, so before 29 February 2104 the most + # recent matching date is eight years earlier, in 2096. + assert ( + resolve_history_time("02-29 22:41:07.980", "2104-01-10 10:00:00") + == "2096-02-29 22:41:07.980000" + ) + def test_dangerous_setting_is_detected_with_the_changing_package(self): settings = parse_bugreport_settings() settings.check_indicators()