Coverage for src/ezcompiler/adapters/_r2_uploader.py: 74.74%
77 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-04 11:22 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-04 11:22 +0000
1# ///////////////////////////////////////////////////////////////
2# R2_UPLOADER - Cloudflare R2 (S3-compatible) uploader implementation
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""R2 uploader - bidirectional S3-compatible backend for the TUF update channel.
8Credentials are read from environment variables only and are never logged
9nor included in error messages. boto3 is imported lazily (extra ``[r2]``).
10"""
12from __future__ import annotations
14# ///////////////////////////////////////////////////////////////
15# IMPORTS
16# ///////////////////////////////////////////////////////////////
17# Standard library imports
18import os
19from pathlib import Path
20from typing import Any
22# Local imports
23from ..shared.exceptions import UploadError
24from .base_uploader import BaseUploader
26# ///////////////////////////////////////////////////////////////
27# CLASSES
28# ///////////////////////////////////////////////////////////////
31class R2Uploader(BaseUploader):
32 """Uploader for Cloudflare R2 (S3-compatible).
34 Configuration keys:
35 bucket (str): Target R2 bucket name (required).
37 Environment variables (required, write credentials):
38 R2_ENDPOINT or R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY.
39 """
41 # ////////////////////////////////////////////////
42 # INITIALIZATION
43 # ////////////////////////////////////////////////
45 def __init__(self, config: dict[str, Any] | None = None) -> None:
46 """Initialize the R2 uploader and build the S3 client.
48 Args:
49 config: Configuration dictionary (requires ``bucket``).
50 """
51 super().__init__(config or {})
52 self._bucket = self._config["bucket"]
53 self._endpoint = self._resolve_endpoint()
54 self._client = self._build_client()
56 # ////////////////////////////////////////////////
57 # PUBLIC METHODS
58 # ////////////////////////////////////////////////
60 def get_uploader_name(self) -> str:
61 """Get the name of this uploader.
63 Returns:
64 str: Name of the uploader.
65 """
66 return "R2 Uploader"
68 def upload(self, source_path: Path, destination: str) -> None:
69 """Upload a file or directory tree under the ``destination`` prefix.
71 For a directory, each file is uploaded with its POSIX path relative
72 to ``source_path`` appended to the prefix. Metadata is uploaded last
73 to keep the published tree TUF-consistent.
75 Args:
76 source_path: Path to the source file or directory.
77 destination: Object key prefix under the bucket.
79 Raises:
80 UploadError: If any object upload fails.
81 """
82 try:
83 self._validate_source_path(source_path)
84 prefix = destination.strip("/")
85 if source_path.is_file(): 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 self._put(source_path, f"{prefix}/{source_path.name}")
87 return
89 files = sorted(p for p in source_path.rglob("*") if p.is_file())
90 # targets/zip d'abord, metadata en dernier (cohérence TUF)
91 files.sort(key=lambda p: "metadata/" in p.as_posix())
92 for file_path in files:
93 rel = file_path.relative_to(source_path).as_posix()
94 self._put(file_path, f"{prefix}/{rel}")
95 except UploadError:
96 raise
97 except Exception as e:
98 raise UploadError(f"R2 upload failed: {e}") from e
100 def download(self, remote_source: str, local_dir: Path) -> None:
101 """Download every object under the ``remote_source`` prefix.
103 Args:
104 remote_source: Object key prefix to fetch.
105 local_dir: Local directory to populate.
107 Raises:
108 UploadError: If a download fails.
110 Note:
111 An empty prefix (first run) is a no-op.
112 """
113 try:
114 prefix = remote_source.strip("/")
115 paginator = self._client.get_paginator("list_objects_v2")
116 for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix):
117 for obj in page.get("Contents", []):
118 key = obj["Key"]
119 rel = key[len(prefix) :].lstrip("/")
120 dest = self._safe_join(local_dir, rel)
121 dest.parent.mkdir(parents=True, exist_ok=True)
122 self._client.download_file(
123 Bucket=self._bucket, Key=key, Filename=str(dest)
124 )
125 except UploadError:
126 raise
127 except Exception as e:
128 raise UploadError(f"R2 download failed: {e}") from e
130 # ////////////////////////////////////////////////
131 # PRIVATE METHODS
132 # ////////////////////////////////////////////////
134 @staticmethod
135 def _safe_join(root: Path, rel: str) -> Path:
136 """Join ``rel`` under ``root``, rejecting path traversal.
138 Object keys come from the remote bucket listing; a crafted key must
139 never let the downloaded file escape ``root``.
141 Raises:
142 UploadError: If ``rel`` resolves outside ``root``.
143 """
144 root_resolved = root.resolve()
145 candidate = (root / rel).resolve()
146 if root_resolved != candidate and root_resolved not in candidate.parents: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 raise UploadError(f"Unsafe object key rejected: {rel}")
148 return candidate
150 def _put(self, source_path: Path, key: str) -> None:
151 """Upload a single file to the bucket under ``key``."""
152 self._client.upload_file(
153 Filename=str(source_path), Bucket=self._bucket, Key=key
154 )
156 def _resolve_endpoint(self) -> str:
157 """Resolve the R2 endpoint URL from env vars.
159 Raises:
160 UploadError: If neither R2_ENDPOINT nor R2_ACCOUNT_ID is set.
161 """
162 endpoint = os.environ.get("R2_ENDPOINT")
163 if endpoint: 163 ↛ 165line 163 didn't jump to line 165 because the condition on line 163 was always true
164 return endpoint
165 account = os.environ.get("R2_ACCOUNT_ID")
166 if not account:
167 raise UploadError("Missing R2_ENDPOINT or R2_ACCOUNT_ID env var")
168 return f"https://{account}.r2.cloudflarestorage.com"
170 def _build_client(self) -> Any:
171 """Build the boto3 S3 client (lazy import, extra ``[r2]``)."""
172 try:
173 import boto3 # noqa: PLC0415 # pyright: ignore[reportMissingImports]
174 except ImportError as exc:
175 raise UploadError("boto3 is not installed; install ezcompiler[r2]") from exc
177 return boto3.client(
178 "s3",
179 endpoint_url=self._endpoint,
180 aws_access_key_id=self._require_env("R2_ACCESS_KEY_ID"),
181 aws_secret_access_key=self._require_env("R2_SECRET_ACCESS_KEY"),
182 region_name="auto",
183 )
185 @staticmethod
186 def _require_env(name: str) -> str:
187 """Return the value of env var ``name`` or raise.
189 Raises:
190 UploadError: If the variable is missing or empty.
191 """
192 value = os.environ.get(name)
193 if not value:
194 raise UploadError(f"Missing required env var: {name}")
195 return value
197 # ////////////////////////////////////////////////
198 # VALIDATION METHODS
199 # ////////////////////////////////////////////////
201 def _validate_config(self) -> None:
202 """Validate R2 uploader configuration.
204 Raises:
205 UploadError: If the required ``bucket`` key is missing.
206 """
207 if not self._config.get("bucket"): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 raise UploadError("Missing required configuration key: bucket")