diff --git a/errata/sources/repos/yum.py b/errata/sources/repos/yum.py index 424e688d..96ecddb7 100644 --- a/errata/sources/repos/yum.py +++ b/errata/sources/repos/yum.py @@ -25,8 +25,8 @@ from packages.utils import get_or_create_package from patchman.signals import pbar_start, pbar_update from security.models import Reference -from util import extract, get_url, run_concurrently -from util.logging import clear_forked_pbar, error_message +from util import extract, get_url, run_concurrently, tz_aware_datetime +from util.logging import clear_forked_pbar, error_message, warning_message def extract_updateinfo(data, url, concurrent_processing=True, max_workers=25): @@ -76,13 +76,31 @@ def process_updateinfo_erratum(update): e_name = update.find('id').text name, ref_type, urls = get_distro_data(e_name, e_type) synopsis = update.find('title').text - issue_date = update.find('issued').attrib.get('date') + issue_date = get_updateinfo_issue_date(update) + if issue_date is None: + warning_message(text=f'No valid issued or updated date for {e_name}, skipping') + update.clear() + return e, created = get_or_create_erratum(name, e_type, issue_date, synopsis) add_updateinfo_erratum_references(e, update, ref_type, urls) add_updateinfo_packages(e, update) update.clear() +def get_updateinfo_issue_date(update): + """ Returns the issued date of an updateinfo erratum, + falling back to the updated date if there is no valid issued date + """ + for tag in ('issued', 'updated'): + element = update.find(tag) + date = element.attrib.get('date') if element is not None else None + if date: + try: + return tz_aware_datetime(date) + except ValueError: + continue + + def get_distro_data(name, e_type): """ Adds distro-specific names and references to an Erratum """ diff --git a/errata/tests/test_yum.py b/errata/tests/test_yum.py new file mode 100644 index 00000000..8f45aa41 --- /dev/null +++ b/errata/tests/test_yum.py @@ -0,0 +1,52 @@ +# Copyright 2026 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from datetime import datetime, timezone + +from defusedxml import ElementTree +from django.test import TestCase + +from errata.sources.repos.yum import get_updateinfo_issue_date + + +def make_update(dates): + return ElementTree.fromstring(f'TEST-1{dates}') + + +class UpdateinfoIssueDateTests(TestCase): + """Tests for getting the issue date of an updateinfo erratum.""" + + def test_issued_date(self): + """Test the issued date is used when present.""" + update = make_update('') + self.assertEqual( + get_updateinfo_issue_date(update), + datetime(2026, 8, 26, 12, 34, 56, tzinfo=timezone.utc), + ) + + def test_falls_back_to_updated_date(self): + """Test the updated date is used when there is no issued date.""" + update = make_update('') + self.assertEqual(get_updateinfo_issue_date(update), datetime(2026, 9, 1, tzinfo=timezone.utc)) + + def test_falls_back_when_issued_invalid(self): + """Test the updated date is used when the issued date is invalid.""" + update = make_update('') + self.assertEqual(get_updateinfo_issue_date(update), datetime(2024, 1, 1, tzinfo=timezone.utc)) + + def test_no_valid_date(self): + """Test None is returned when there is no valid date.""" + self.assertIsNone(get_updateinfo_issue_date(make_update(''))) diff --git a/util/__init__.py b/util/__init__.py index 037b0d8a..8f3649e0 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -18,6 +18,7 @@ import bz2 import lzma import os +import re import zlib import magic @@ -37,7 +38,6 @@ from django.conf import settings from django.utils.dateparse import parse_datetime -from django.utils.timezone import make_aware from requests.exceptions import ConnectionError, HTTPError, Timeout from tenacity import ( retry, retry_if_exception_type, stop_after_attempt, wait_exponential, @@ -293,15 +293,16 @@ def tz_aware_datetime(date): """ Ensure a datetime is timezone-aware Returns the tz-aware datetime object """ - if isinstance(date, int) or is_epoch_time(date): - parsed_date = datetime.fromtimestamp(int(date)) - elif isinstance(date, str): - parsed_date = parse_datetime(date) - else: + if isinstance(date, datetime): parsed_date = date - parsed_date = parsed_date.replace(tzinfo=timezone.utc) - if not parsed_date.tzinfo: - parsed_date = make_aware(parsed_date) + elif isinstance(date, int) or is_epoch_time(date): + parsed_date = datetime.fromtimestamp(int(date), tz=timezone.utc) + else: + parsed_date = parse_datetime(re.sub(r'\s*UTC$', '+00:00', str(date).strip())) + if parsed_date is None: + raise ValueError(f'Invalid datetime: {date}') + if parsed_date.tzinfo is None: + parsed_date = parsed_date.replace(tzinfo=timezone.utc) return parsed_date diff --git a/util/tests/test_utils.py b/util/tests/test_utils.py index 23735348..9a2474a3 100644 --- a/util/tests/test_utils.py +++ b/util/tests/test_utils.py @@ -16,6 +16,7 @@ import gzip import hashlib +from datetime import datetime, timezone from io import BytesIO from unittest import skipIf from unittest.mock import MagicMock @@ -249,6 +250,27 @@ def test_tz_aware_datetime_from_string(self): result = tz_aware_datetime(date_str) self.assertIsNotNone(result.tzinfo) + def test_tz_aware_datetime_epoch_is_utc(self): + """Test epoch timestamps are converted as UTC, not local time.""" + expected = datetime(2024, 1, 1, tzinfo=timezone.utc) + self.assertEqual(tz_aware_datetime(1704067200), expected) + self.assertEqual(tz_aware_datetime('1704067200'), expected) + + def test_tz_aware_datetime_utc_suffix(self): + """Test a trailing textual UTC timezone is accepted.""" + result = tz_aware_datetime('2026-08-26 12:34:56 UTC') + self.assertEqual(result, datetime(2026, 8, 26, 12, 34, 56, tzinfo=timezone.utc)) + + def test_tz_aware_datetime_keeps_offset(self): + """Test an explicit timezone offset is not overwritten with UTC.""" + result = tz_aware_datetime('2026-08-26 12:34:56+05:00') + self.assertEqual(result, datetime(2026, 8, 26, 7, 34, 56, tzinfo=timezone.utc)) + + def test_tz_aware_datetime_invalid(self): + """Test an unparseable datetime raises ValueError.""" + with self.assertRaises(ValueError): + tz_aware_datetime('not a date') + @override_settings( CELERY_TASK_ALWAYS_EAGER=True,