403Webshell
Server IP : 172.67.201.108  /  Your IP : 216.73.216.11
Web Server : Apache/2.4.68 (Amazon Linux) OpenSSL/3.5.5
System : Linux ip-172-31-69-123.ec2.internal 6.1.176-223.369.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Fri Jul 24 13:34:27 UTC 2026 x86_64
User : ec2-user ( 1000)
PHP Version : 8.4.23
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : ON
Directory :  /lib/python3.9/site-packages/dnf_support_info_plugin/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /lib/python3.9/site-packages/dnf_support_info_plugin/file_cache.py
# Copyright Amazon.com, Inc. and its affiliates. All Rights Reserved.
#
# This program 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 2
# of the License.
#
# This program 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 this program. If not, see <http://www.gnu.org/licenses/gpl-2.0.html>.

"""Remote file caching with metadata validation."""

import hashlib
import io
import json
import logging
import os
import tempfile
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from http.client import HTTPResponse
from pathlib import Path
from typing import Dict, List, Optional, TypedDict
from urllib.error import HTTPError, URLError
from urllib.parse import unquote, urlparse
from urllib.request import Request, urlopen

import dnf.crypto
import dnf.yum.misc
import gpg
import gpg.errors
from dnf.exceptions import Error as DnfError

from .const import DEFAULT_CACHE_DURATION_SECONDS
from .utils import validate_metadata_expire

logger = logging.getLogger("dnf.plugin")

GPG_VERIFY_RETRIES = 3


class HttpMetadata(TypedDict):
    """HTTP response metadata for cache validation.

    Attributes:
        url: The source URL
        etag: HTTP ETag header value (for cache validation)
        last_modified: HTTP Last-Modified header value
        size: HTTP Content-Length header value
        content_type: HTTP Content-Type header value
    """

    url: str
    etag: str
    last_modified: str
    size: str
    content_type: str


class CachedMetadata(HttpMetadata):
    """Cached metadata stored on disk, extends HttpMetadata with timestamp.

    Attributes:
        timestamp: ISO format timestamp when cache was last updated
        (inherits: url, etag, last_modified, size, content_type)
    """

    timestamp: str


@dataclass
class FetchResult:
    """Result from fetching file content, bundling data with metadata."""

    data: bytes
    metadata: Optional[HttpMetadata] = None


class GpgVerifier:
    """Verifies detached GPG signatures against a set of trusted GPG keys.

    Keys are specified as URLs (``file://`` or ``https://``) matching the
    ``gpgkey`` config option convention.  On first use each key is fetched and
    imported into a persistent pubring directory; subsequent runs skip keys
    that are already present (Trust On First Use).
    """

    def __init__(self, gpgkey: List[str], pubring_dir: str) -> None:
        """Initialize the verifier.

        Args:
            gpgkey: List of GPG key URLs (``file://`` or ``https://``).
            pubring_dir: Path to the persistent GPG pubring directory.
        """
        self.gpgkey = gpgkey
        self.pubring_dir = pubring_dir

    def _ensure_keys_imported(self) -> bool:
        """Import any keys from gpgkey URLs that are not yet in the pubring.

        Uses ``dnf.crypto.retrieve()`` to fetch each key URL and
        ``dnf.yum.misc.import_key_to_pubring()`` to store it.  Keys that are
        already present are skipped.

        Returns:
            True if at least one key is available in the pubring.
            False if the pubring is empty after attempting all imports.
        """
        known_keys = dnf.crypto.keyids_from_pubring(self.pubring_dir)

        allowed_schemes = FileHandler.get_supported_schemes()
        for url in self.gpgkey:
            scheme = urlparse(url).scheme
            if scheme not in allowed_schemes:
                logger.warning(f"Skipping gpgkey URL with unsupported scheme '{scheme}': {url}")
                continue
            try:
                keyinfos = dnf.crypto.retrieve(url)
            except Exception as e:
                logger.warning(f"Failed to retrieve GPG key from {url}: {e}")
                continue

            for keyinfo in keyinfos:
                if keyinfo.id_ in known_keys:
                    logger.debug(f"GPG key 0x{keyinfo.id_} already imported, skipping")
                    continue
                dnf.crypto.log_key_import(keyinfo)
                try:
                    dnf.yum.misc.import_key_to_pubring(
                        keyinfo.raw_key,
                        keyinfo.short_id,
                        gpgdir=self.pubring_dir,
                        make_ro_copy=False,
                    )
                    known_keys.append(keyinfo.id_)
                    logger.debug(f"Imported GPG key 0x{keyinfo.id_} into pubring")
                except Exception as e:
                    logger.warning(f"Failed to import key 0x{keyinfo.id_}: {e}")

        if not known_keys:
            logger.error(
                f"No GPG keys available in pubring {self.pubring_dir}. "
                "Ensure gpgkey URLs are correct and reachable."
            )
            return False
        return True

    def verify(self, data: bytes, signature: bytes) -> bool:
        """Verify that *data* matches the detached *signature*.

        Ensures keys are imported into the pubring, then verifies the
        signature using ``gpg.Context`` with GNUPGHOME set to the pubring
        directory so that only the plugin's trusted keys are consulted.

        Args:
            data: Raw bytes of the file to verify.
            signature: Raw bytes of the detached signature.

        Returns:
            True if the signature is valid and was made by a trusted key.
            False if verification fails for any reason.
        """
        if not self._ensure_keys_imported():
            return False

        try:
            with io.BytesIO(data) as data_io, io.BytesIO(signature) as sig_io:
                with dnf.crypto.pubring_dir(self.pubring_dir):
                    gpg_context = gpg.Context()
                    _, result = gpg_context.verify(data_io, sig_io)
            logger.debug(f"GPG signature verification succeeded: {result}")
            return True
        except gpg.errors.BadSignatures as e:
            logger.error(f"GPG signature verification failed: {e}")
            return False
        except Exception as e:
            logger.error(f"Unexpected error during GPG verification: {e}")
            return False


class FileCache:
    """Standalone file cache manager with metadata validation.

    This class encapsulates all caching logic and can be used by
    any handler that needs caching capabilities.
    """

    def __init__(self, url: str, metadata_expire: timedelta, cache_dir: Path) -> None:
        """Initialize cache for a specific URL.

        Args:
            url: The URL to cache
            metadata_expire: How long cache is valid before revalidation
            cache_dir: Directory for storing cached files
        """
        self.url = url
        self.cache_dir = cache_dir
        self.metadata_expire = metadata_expire

        # Setup cache paths — use 0o700 so cached data is only accessible by
        # the owning user (defense-in-depth alongside per-file 0o600 perms).
        self.cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
        logger.debug(f"Using cache directory: {self.cache_dir}")

        # Log cache expiration
        seconds = metadata_expire.total_seconds()
        cache_messages = {
            -1: "never expires (infinite)",
            0: "always checks for updates",
        }
        expiry_msg = cache_messages.get(seconds, f"expires after {seconds:.0f} seconds")
        logger.debug(f"Cache {expiry_msg} for {url}")

        parsed_url = urlparse(self.url)
        basename = os.path.basename(parsed_url.path)
        url_hash = hashlib.sha256(self.url.encode()).hexdigest()[:8]

        # Include URL hash to prevent collisions between different URLs with same basename
        if basename:
            self.cache_filename = f"{basename}_{url_hash}"
        else:
            self.cache_filename = f"cached_{url_hash}"

        self.cached_file_path = self.cache_dir / self.cache_filename
        self.metadata_file_path = self.cache_dir / f"{self.cache_filename}.metadata"

    def clear(self) -> None:
        """Remove cached file and metadata from disk."""
        self.cached_file_path.unlink(missing_ok=True)
        self.metadata_file_path.unlink(missing_ok=True)

    def _is_cache_valid(self) -> bool:
        """Check if the cached file is within the cache duration.

        Returns:
            True if cache is still valid, False otherwise
        """
        metadata = self._load_metadata()
        if not metadata or "timestamp" not in metadata:
            return False

        # Special case: -1 seconds means infinite cache (never expires)
        if self.metadata_expire.total_seconds() == -1:
            return True

        cache_time = datetime.fromisoformat(metadata["timestamp"])
        return datetime.now(timezone.utc) - cache_time < self.metadata_expire

    def _load_metadata(self) -> Optional[CachedMetadata]:
        """Load cached metadata from disk.

        Returns:
            Cached metadata dictionary if file exists and is valid, None otherwise
        """
        if not self.metadata_file_path.exists():
            return None

        try:
            return json.loads(self.metadata_file_path.read_text())
        except Exception as e:
            logger.warning(f"Failed to load metadata: {e}")
            return None

    def _save_metadata(self, metadata: HttpMetadata) -> None:
        """Write metadata to disk with current timestamp using atomic write.

        Args:
            metadata: HTTP response metadata to save

        Raises:
            OSError: If the metadata file cannot be written to disk.
        """
        metadata_with_timestamp = dict(metadata)
        metadata_with_timestamp["timestamp"] = datetime.now(timezone.utc).isoformat()

        tmp_path: Optional[str] = None
        try:
            with tempfile.NamedTemporaryFile(
                mode="w", dir=self.metadata_file_path.parent, delete=False, suffix=".tmp"
            ) as tmp_file:
                tmp_file.write(json.dumps(metadata_with_timestamp, indent=2))
                tmp_file.flush()
                os.fsync(tmp_file.fileno())
                tmp_path = tmp_file.name

            os.chmod(tmp_path, 0o600)
            os.replace(tmp_path, self.metadata_file_path)
        except Exception as e:
            # Clean up temp file before re-raising so we don't leave stale .tmp files
            if tmp_path is not None:
                try:
                    Path(tmp_path).unlink(missing_ok=True)
                except Exception as cleanup_error:
                    logger.debug(f"Failed to clean up temp file: {cleanup_error}")
            raise OSError(f"Failed to write metadata: {e}") from e

    def _update_timestamp(self) -> None:
        """Update the cache timestamp to extend cache validity.

        Reads current metadata, removes the timestamp field, and saves it again
        to trigger creation of a fresh timestamp.
        """
        metadata = self._load_metadata()
        if metadata:
            # Remove timestamp so _save_metadata adds a fresh one
            metadata_without_timestamp: HttpMetadata = {
                "url": metadata.get("url", ""),
                "etag": metadata.get("etag", ""),
                "last_modified": metadata.get("last_modified", ""),
                "size": metadata.get("size", ""),
                "content_type": metadata.get("content_type", ""),
            }
            self._save_metadata(metadata_without_timestamp)

    def _metadata_matches(
        self, cached_metadata: Optional[CachedMetadata], remote_metadata: HttpMetadata
    ) -> bool:
        """Check if cached and remote ETags match (case-insensitive for safety).

        Args:
            cached_metadata: Cached metadata loaded from disk
            remote_metadata: Fresh metadata from HTTP HEAD request

        Returns:
            True if ETags match (indicating unchanged content), False otherwise
        """
        if cached_metadata is None:
            return False
        # Normalize ETags to lowercase for comparison (defensive against non-compliant servers)
        remote_etag = remote_metadata.get("etag", "").lower()
        cached_etag = cached_metadata.get("etag", "").lower()
        return remote_etag == cached_etag and remote_etag != ""


class FileHandler(ABC):
    """Abstract base class for file protocol handlers.

    Implements the Template Method pattern for GPG-verified file access:

    1. ``get_file_path()`` (defined here) orchestrates the full flow —
       fetching content, verifying the GPG signature when requested, and
       persisting/returning the file path.
    2. Subclasses implement the protocol-specific primitives:
       ``_fetch_content()``, ``_fetch_signature()``, and ``_persist_content()``.

    GPG state (``gpgcheck``, ``gpg_verifier``, ``asc_url``) is stored on the
    base class so the policy is always enforced in one place regardless of
    which handler is active.
    """

    # Class-level registry mapping URL schemes to handler classes
    _handlers: Dict[str, type["FileHandler"]] = {}

    def __init__(
        self,
        url: str,
        gpgcheck: bool = False,
        gpgkey: Optional[List[str]] = None,
        cache_dir: Optional[str] = None,
    ) -> None:
        """Initialize file handler.

        Args:
            url: The URL to access.
            gpgcheck: When True, verify file content against a detached GPG
                      signature before returning the path.  The signature
                      location is protocol-specific (``url + ".asc"`` for HTTPS,
                      ``<local_path>.asc`` for file://).
            gpgkey: List of GPG key URLs used when gpgcheck is True.
            cache_dir: Directory for storing cached files.
                       The GPG pubring is stored at ``<cache_dir>/pubring/`` so that keys
                       are co-located with the data they verify (matching DNF's repo
                       metadata pattern) and are cleaned together with the cache.
        """
        self.url = url
        self.gpgcheck = gpgcheck
        self.asc_url = url + ".asc"
        if gpgcheck:
            if not cache_dir:
                raise ValueError("cache_dir is required when gpgcheck is enabled")
            pubring_dir = os.path.join(cache_dir, "pubring")
            self.gpg_verifier = GpgVerifier(gpgkey or [], pubring_dir=pubring_dir)
        else:
            self.gpg_verifier = None

    # ------------------------------------------------------------------
    # Template method — NOT overridden by subclasses
    # ------------------------------------------------------------------

    def get_file_path(self) -> Optional[str]:
        """Return a path to the file, verifying its GPG signature when enabled.

        First checks whether a valid cached copy is available via
        ``_get_cached_path()``.  If so, returns it immediately — no network
        request or GPG re-verification is performed (the file was already
        verified when it was first fetched and cached).

        For a cache miss (or non-caching handlers like ``LocalFileHandler``),
        calls ``_fetch_content()`` to obtain the raw bytes, then (if
        ``gpgcheck`` is True) calls ``_fetch_signature()`` and verifies before
        delegating to ``_persist_content()`` to write/return the path.

        Returns:
            Path to the verified file, or None if the content could not be
            fetched or GPG verification failed.
        """
        # Fast path: return cached copy without GPG re-verification.
        # Let the subclass decide how to handle caching errors — exceptions
        # from _get_cached_path (e.g. filesystem errors in _is_cache_valid)
        # are caught here so the template method can fall back gracefully.
        try:
            cached = self._get_cached_path()
        except Exception as e:
            logger.warning(f"Error in caching logic: {e}")
            # Give subclasses a chance to return a stale cached file
            try:
                cached = self._get_stale_cached_path()
            except Exception as cleanup_error:
                logger.debug(f"Failed to get stale cached path: {cleanup_error}")
                cached = None
            if cached is not None:
                logger.info("Using cached file as fallback")
                return cached
            cached = None
        if cached is not None:
            return cached

        retries = self._gpg_retries if self.gpgcheck else 1
        last_error = None
        for attempt in range(retries):
            if attempt > 0:
                time.sleep(attempt)
            try:
                result = self._fetch_content()
            except DnfError:
                raise
            except Exception as e:
                last_error = f"Failed to fetch content from {self.url}: {e}"
                continue
            if not self.gpgcheck:
                return self._persist_content(result)
            try:
                signature = self._fetch_signature()
            except DnfError as e:
                last_error = str(e)
                continue
            except Exception as e:
                last_error = f"Failed to fetch GPG signature from {self.asc_url}: {e}"
                continue
            if self.gpg_verifier.verify(result.data, signature):
                logger.debug(f"GPG verification passed for {self.url}")
                return self._persist_content(result)
            last_error = "GPG signature verification failed. The file may have been tampered with."
            if attempt < retries - 1:
                logger.warning(f"GPG verification failed, retrying ({attempt + 1}/{retries})...")

        # All retries exhausted - try stale cache fallback
        stale = self._get_stale_cached_path()
        if stale:
            logger.info("Falling back to previously verified cached copy")
            return stale
        raise DnfError(f"Failed to retrieve {self.url}: {last_error}")

    def _get_cached_path(self) -> Optional[str]:
        """Return a path to the cached file if it is still valid, else None.

        The default implementation returns None (no caching).  Subclasses
        that maintain a local cache override this to short-circuit the
        fetch/verify/persist pipeline when fresh content is already on disk.
        """
        return None

    def _get_stale_cached_path(self) -> Optional[str]:
        """Return the path to any cached file, even if stale/expired.

        Called only when ``_get_cached_path`` raises an exception, to allow
        fallback to a stale copy rather than failing completely.  The default
        implementation returns None (no cache).  Subclasses with a cache
        override this.
        """
        return None

    # ------------------------------------------------------------------
    # Abstract primitives — implemented by each subclass
    # ------------------------------------------------------------------

    @abstractmethod
    def _fetch_content(self) -> FetchResult:
        """Fetch file content and metadata from the source.

        For cached handlers this may return already-cached bytes or trigger
        a download.  For local handlers it reads directly from disk.

        Returns:
            FetchResult with data bytes and optional metadata.

        Raises:
            DnfError: If the file cannot be found or accessed.
        """
        pass

    @abstractmethod
    def _fetch_signature(self) -> bytes:
        """Return the raw bytes of the detached GPG signature (.asc).

        Only called when gpgcheck=True.

        Raises:
            DnfError: If the signature file cannot be found or fetched.
        """
        pass

    @abstractmethod
    def _persist_content(self, result: FetchResult) -> Optional[str]:
        """Persist verified content and return its path.

        For HTTPS handlers this writes to the cache and returns the cache path.
        For local handlers this simply returns the original local path (no
        copy needed).

        Args:
            result: FetchResult containing verified data and metadata.

        Returns:
            Path to the persisted file, or None on error.
        """
        pass

    def clear_cache(self) -> None:
        """Clear cached files for this handler.

        Default implementation does nothing (safe for handlers without caching).
        Handlers with caching override this to clear their cache.
        """
        pass

    @property
    def _gpg_retries(self) -> int:
        """Number of GPG verification retry attempts.

        Returns GPG_VERIFY_RETRIES by default. LocalFileHandler overrides
        to return 1 (no retry) since local files don't change between attempts.
        """
        return GPG_VERIFY_RETRIES

    @classmethod
    def register_handler(cls, scheme: str, handler_class: type["FileHandler"]) -> None:
        """Register a handler class for a specific URL scheme.

        Args:
            scheme: The URL scheme (e.g., 'http', 'https', 'file', etc.)
            handler_class: The FileHandler subclass to handle this scheme
        """
        cls._handlers[scheme] = handler_class
        logger.debug(f"Registered handler {handler_class.__name__} for scheme '{scheme}'")

    @classmethod
    def get_supported_schemes(cls) -> set:
        """Return the set of URL schemes supported by registered handlers.

        Returns:
            Set of scheme strings (e.g. {'https', 'file'})
        """
        return set(cls._handlers.keys())

    @classmethod
    def create(
        cls,
        url: str,
        metadata_expire: int = DEFAULT_CACHE_DURATION_SECONDS,
        request_timeout: int = 30,
        retries: int = 3,
        gpgcheck: bool = False,
        gpgkey: Optional[List[str]] = None,
        cache_dir: Optional[str] = None,
    ) -> "FileHandler":
        """Factory method to create appropriate file handler based on URL scheme.

        Args:
            url: HTTP(S) or file:// URL (variables should already be resolved)
            metadata_expire: Cache validity duration in seconds
            request_timeout: HTTP request timeout in seconds (ignored for file:// URLs)
            retries: Number of retry attempts for transient errors (ignored for file:// URLs)
            gpgcheck: When True, verify downloaded content against a detached GPG
                      signature before caching (HTTPS handlers only). The signature
                      is always fetched from url + ".asc".
            gpgkey: List of GPG key URLs (``file://`` or ``https://``) used when
                    gpgcheck is True. Matches the ``gpgkey`` config option convention.
            cache_dir: Directory for storing cached files. The GPG pubring is stored
                    at ``<cache_dir>/pubring/``.

        Returns:
            FileHandler instance for the appropriate URL scheme

        Raises:
            ValueError: If parameters are invalid or unsupported URL scheme
        """
        # Validate inputs
        if not url or not url.strip():
            raise ValueError("url must not be empty")

        validate_metadata_expire(metadata_expire)

        if request_timeout <= 0:
            raise ValueError("request_timeout must be positive")
        if retries < 0:
            raise ValueError("retries must be non-negative")

        # Determine scheme
        parsed_url = urlparse(url)
        scheme = parsed_url.scheme or "http"

        # Look up handler in registry
        handler_class = cls._handlers.get(scheme)
        if not handler_class:
            raise ValueError(f"Unsupported URL scheme: {scheme}")

        # Build kwargs common to all handlers
        kwargs: Dict = {
            "url": url,
            "metadata_expire": timedelta(seconds=metadata_expire),
            "request_timeout": request_timeout,
            "retries": retries,
            "gpgcheck": gpgcheck,
            "gpgkey": gpgkey or [],
            "cache_dir": cache_dir,
        }

        return handler_class(**kwargs)


class LocalFileHandler(FileHandler):
    """Handler for file:// URLs with optional GPG signature verification.

    Local files are accessed directly (no caching) since they already reside
    on disk.  When ``gpgcheck=True`` the detached signature is read from
    ``<path>.asc`` and verified via the base-class template method before the
    file path is returned.
    """

    def __init__(
        self,
        url: str,
        metadata_expire: timedelta,
        request_timeout: int = 30,
        retries: int = 3,
        gpgcheck: bool = False,
        gpgkey: Optional[List[str]] = None,
        cache_dir: Optional[str] = None,
    ) -> None:
        """Initialize local file handler.

        Args:
            url: file:// URL to access
            metadata_expire: Cache validity duration (not used, for API consistency)
            request_timeout: HTTP request timeout (not used, for API consistency)
            retries: Number of retry attempts (not used, for API consistency)
            gpgcheck: When True, verify the file against a detached GPG signature
                      read from ``<local_path>.asc`` before returning the path.
            gpgkey: List of GPG key URLs used for signature verification.
            cache_dir: Cache directory path (required when gpgcheck is True).
                       The GPG pubring is stored at ``<cache_dir>/pubring/``.
        """
        super().__init__(url, gpgcheck=gpgcheck, gpgkey=gpgkey, cache_dir=cache_dir)

        # Store cache_dir for consistency (local files don't cache but may need pubring)
        if not cache_dir:
            raise ValueError("cache_dir is required")
        self.cache_dir = Path(cache_dir)
        self.metadata_expire = metadata_expire

        # Convert file:// URL to local path
        parsed_url = urlparse(self.url)
        self.source_file_path = Path(unquote(parsed_url.path))
        logger.debug(f"Detected file:// URL, source path: {self.source_file_path}")

    def _fetch_content(self) -> FetchResult:
        """Read and return the local file content.

        Returns:
            FetchResult with file data (metadata is None for local files).

        Raises:
            DnfError: If the file does not exist or cannot be accessed.
        """
        try:
            if not self.source_file_path.exists():
                raise DnfError(f"File not found: {self.url}")
            logger.debug(f"Reading local file: {self.source_file_path}")
            return FetchResult(data=self.source_file_path.read_bytes(), metadata=None)
        except DnfError:
            raise
        except Exception as e:
            raise DnfError(f"Failed to access file: {self.url}") from e

    def _fetch_signature(self) -> bytes:
        """Read the detached GPG signature from ``<source_file_path>.asc``.

        Returns:
            Raw signature bytes.

        Raises:
            DnfError: If the ``.asc`` sidecar file does not exist or cannot be read.
        """
        asc_path = Path(str(self.source_file_path) + ".asc")
        if not asc_path.exists():
            raise DnfError(
                f"GPG signature file not found: {asc_path}. "
                f"Expected alongside {self.source_file_path}"
            )
        return asc_path.read_bytes()

    def _persist_content(self, result: FetchResult) -> Optional[str]:
        """Return the original local file path — no copy needed.

        Args:
            result: FetchResult (not used; file is already on disk).

        Returns:
            Path to the local source file.
        """
        return str(self.source_file_path)

    @property
    def _gpg_retries(self) -> int:
        """Return 1 (no retry) since local files don't change between attempts."""
        return 1


class HttpsFileHandler(FileHandler):
    """Handler for HTTPS URLs with ETag-based caching and optional GPG verification.

    Only HTTPS URLs are supported; plain HTTP is rejected at request time
    to enforce secure transport.  Uses FileCache to handle caching logic.
    """

    def __init__(
        self,
        url: str,
        metadata_expire: timedelta,
        request_timeout: int,
        retries: int,
        gpgcheck: bool = False,
        gpgkey: Optional[List[str]] = None,
        cache_dir: Optional[str] = None,
    ) -> None:
        """Initialize HTTPS file handler with caching.

        Args:
            url: HTTPS URL to cache
            metadata_expire: Cache validity duration
            request_timeout: HTTP request timeout in seconds
            retries: Number of retry attempts for transient errors
            gpgcheck: When True, verify the downloaded file against a detached
                      GPG signature before caching it. The signature is always
                      fetched from url + ".asc".
            gpgkey: List of GPG key URLs used for signature verification.
            cache_dir: Directory for storing cached files.
                       The GPG pubring is stored at ``<cache_dir>/pubring/``.
        """
        super().__init__(url, gpgcheck=gpgcheck, gpgkey=gpgkey, cache_dir=cache_dir)

        if not cache_dir:
            raise ValueError("cache_dir is required")
        self.cache = FileCache(url, metadata_expire, cache_dir=Path(cache_dir))

        # HTTP-specific settings
        self.request_timeout = request_timeout
        self.retries = retries

    @property
    def cache_dir(self) -> Path:
        """Get the cache directory path."""
        return self.cache.cache_dir

    @property
    def metadata_expire(self) -> timedelta:
        """Get the metadata expiration duration."""
        return self.cache.metadata_expire

    # ------------------------------------------------------------------
    # FileHandler abstract primitive implementations
    # ------------------------------------------------------------------

    def _get_cached_path(self) -> Optional[str]:
        """Return the cached file path if the cache is still valid, else None.

        Overrides the base-class no-op so that valid cached files bypass the
        full fetch/verify/persist pipeline in ``get_file_path()``.

        Exceptions from ``_is_cache_valid()`` (e.g. corrupt metadata or
        filesystem errors) propagate to ``get_file_path()`` which logs them as
        "Error in caching logic" and falls back via ``_get_stale_cached_path``.

        Exceptions from the remote HEAD check are caught here and logged as
        "Failed to check source" before falling back to the stale cached file.

        When the cache is stale and the remote ETag check succeeds, the
        cache timestamp is extended.  When the ETag differs, None is returned
        so ``get_file_path`` proceeds to a fresh fetch via ``_fetch_content``.
        """
        if not self.cache.cached_file_path.exists():
            return None

        # _is_cache_valid exceptions intentionally propagate to get_file_path
        if self.cache._is_cache_valid():
            logger.debug("Cache is valid, using cached file")
            return str(self.cache.cached_file_path)

        # Cache exists but is stale — check if the remote content changed.
        # Network / metadata errors are caught here; on failure, fall back to
        # the stale cached file rather than propagating a transient error.
        logger.debug("Cache expired, checking if source has changed")
        try:
            remote_metadata = self._get_metadata()
            cached_metadata = self.cache._load_metadata()
            if self.cache._metadata_matches(cached_metadata, remote_metadata):
                logger.debug("Source unchanged, extending cache timestamp")
                self.cache._update_timestamp()
                return str(self.cache.cached_file_path)
            # Remote content changed — _fetch_content will capture fresh metadata
            return None
        except Exception as e:
            logger.warning(f"Failed to check source: {e}")
            # Fallback to stale cached file rather than failing completely
            if self.cache.cached_file_path.exists():
                logger.info("Using cached file as fallback")
                return str(self.cache.cached_file_path)
            return None

    def _get_stale_cached_path(self) -> Optional[str]:
        """Return the stale cached file path if one exists on disk, else None.

        Called by ``FileHandler.get_file_path`` when ``_get_cached_path``
        raises an unexpected exception (e.g. filesystem error in
        ``_is_cache_valid``).  Allows the handler to serve a stale copy rather
        than failing completely.
        """
        if self.cache.cached_file_path.exists():
            return str(self.cache.cached_file_path)
        return None

    def _fetch_content(self) -> FetchResult:
        """Download the file from the HTTPS URL.

        Returns:
            FetchResult with file data and HTTP metadata from the response.
        """
        logger.debug(f"Downloading file from {self.url}")
        response = self._make_http_request(self.url, method="GET")
        data = response.read()
        metadata = self._extract_metadata(response)
        return FetchResult(data=data, metadata=metadata)

    def _fetch_signature(self) -> bytes:
        """Download the detached GPG signature from ``asc_url``.

        Raises:
            DnfError: If the signature cannot be fetched or exceeds size limit.
        """
        logger.debug(f"Downloading GPG signature from {self.asc_url}")
        try:
            response = self._make_http_request(self.asc_url, method="GET")
            return response.read()
        except DnfError:
            raise
        except Exception as e:
            raise DnfError(f"Unable to retrieve GPG signature from {self.asc_url}: {e}") from e

    def _persist_content(self, result: FetchResult) -> Optional[str]:
        """Write verified bytes to the cache and record metadata.

        Args:
            result: FetchResult containing verified data and metadata.

        Returns:
            Path to the cached file, or None on write error.
        """
        if result.metadata is None:
            raise ValueError("HttpsFileHandler requires metadata in FetchResult")
        tmp_path: Optional[str] = None
        try:
            with tempfile.NamedTemporaryFile(
                mode="wb", dir=self.cache.cached_file_path.parent, delete=False, suffix=".tmp"
            ) as tmp_file:
                tmp_file.write(result.data)
                tmp_file.flush()
                os.fsync(tmp_file.fileno())
                tmp_path = tmp_file.name
            os.chmod(tmp_path, 0o600)
            os.replace(tmp_path, self.cache.cached_file_path)
            self.cache._save_metadata(result.metadata)
            return str(self.cache.cached_file_path)
        except Exception as e:
            if tmp_path is not None:
                try:
                    Path(tmp_path).unlink(missing_ok=True)
                except OSError as cleanup_error:
                    logger.debug(f"Failed to clean up temp file: {cleanup_error}")
            try:
                self.cache.cached_file_path.unlink(missing_ok=True)
            except OSError as cleanup_error:
                logger.debug(f"Failed to clean up cached file: {cleanup_error}")
            raise DnfError(f"Failed to persist {self.url} to cache: {e}") from e

    def clear_cache(self) -> None:
        """Clear cached files and metadata via the composed cache object."""
        self.cache.clear()

    def _extract_metadata(self, response: HTTPResponse) -> HttpMetadata:
        """Extract metadata from an HTTP response.

        Args:
            response: HTTP response (from GET or HEAD request).

        Returns:
            Metadata dictionary with ETag, Last-Modified, etc.
        """
        etag = response.headers.get("ETag", "")
        etag = etag.strip('"') if etag else ""
        return {
            "url": self.url,
            "etag": etag,
            "last_modified": response.headers.get("Last-Modified", ""),
            "size": response.headers.get("Content-Length", ""),
            "content_type": response.headers.get("Content-Type", ""),
        }

    def _get_metadata(self) -> HttpMetadata:
        """Get metadata from HTTP URL using HEAD request.

        Returns:
            Metadata dictionary extracted from HTTP headers
        """
        logger.debug(f"Fetching HTTP metadata for {self.url}")
        response = self._make_http_request(self.url, method="HEAD")
        return self._extract_metadata(response)

    def _is_transient_error(self, error: Exception) -> bool:
        """Determine if an error is transient and worth retrying.

        Args:
            error: The exception to evaluate

        Returns:
            True if error is transient and retry should be attempted, False otherwise
        """
        if isinstance(error, HTTPError):
            # Retry on server errors (5xx) and 429 (Too Many Requests)
            return error.code >= 500 or error.code == 429
        if isinstance(error, URLError):
            # Network errors are generally transient
            return True
        return False

    def _make_http_request(self, url: str, method: str = "GET") -> HTTPResponse:
        """Make an HTTP request with retry logic.

        Args:
            url: The URL to request
            method: HTTP method (GET or HEAD)

        Returns:
            Response from the server

        Raises:
            ValueError: If URL scheme is not https
            HTTPError: On HTTP protocol errors (non-transient or after exhausting retries)
            URLError: On network errors (non-transient or after exhausting retries)
        """
        # Validate URL scheme for security (prevents file:// and other schemes)
        parsed_url = urlparse(url)
        if parsed_url.scheme != "https":
            raise ValueError(
                f"Only https scheme is allowed for HTTP requests, got: {parsed_url.scheme}"
            )

        last_exception = None
        attempts = self.retries + 1

        for attempt in range(attempts):
            try:
                request = Request(url, method=method)
                request.add_header("User-Agent", "DNFSupportInfoPlugin/1.0")
                return urlopen(request, timeout=self.request_timeout)
            except Exception as e:
                last_exception = e

                if not self._is_transient_error(e):
                    logger.debug(f"Non-transient error: {e}")
                    raise

                if attempt >= self.retries:
                    logger.debug(f"Max retries ({self.retries}) exhausted: {e}")
                    raise

                logger.debug(
                    f"Transient error on attempt {attempt + 1}/{attempts}: {e}. Retrying..."
                )

        if last_exception:
            raise last_exception
        raise DnfError("Unexpected state in retry logic")


# Register handlers at module level
FileHandler.register_handler("file", LocalFileHandler)
FileHandler.register_handler("https", HttpsFileHandler)

Youez - 2016 - github.com/yon3zu
LinuXploit