Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions errata/sources/repos/yum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
"""
Expand Down
52 changes: 52 additions & 0 deletions errata/tests/test_yum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Copyright 2026 Marcus Furlong <furlongm@gmail.com>
#
# 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 <http://www.gnu.org/licenses/>

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'<update type="security"><id>TEST-1</id>{dates}</update>')


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('<issued date="2026-08-26 12:34:56"/><updated date="2026-09-01 00:00:00"/>')
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('<updated date="2026-09-01 00:00:00 UTC"/>')
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('<issued date="bogus"/><updated date="1704067200"/>')
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('<issued/>')))
19 changes: 10 additions & 9 deletions util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import bz2
import lzma
import os
import re
import zlib

import magic
Expand All @@ -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,
Expand Down Expand Up @@ -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


Expand Down
22 changes: 22 additions & 0 deletions util/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading