Coverage for src/ezcompiler/adapters/_server_uploader.py: 72.64%
143 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 16:37 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 16:37 +0000
1# ///////////////////////////////////////////////////////////////
2# SERVER_UPLOADER - Remote server uploader implementation
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""
7Server uploader - HTTP/HTTPS remote server upload handler for EzCompiler.
9This module provides functionality for uploading files to remote servers
10via HTTP/HTTPS POST requests, with support for authentication, retry logic,
11and SSL verification.
13Note: Protocols layer should not perform logging directly. Logging is handled
14by the service layer that orchestrates upload operations.
15"""
17from __future__ import annotations
19# ///////////////////////////////////////////////////////////////
20# IMPORTS
21# ///////////////////////////////////////////////////////////////
22# Standard library imports
23import os
24from pathlib import Path
25from typing import Any
27# Third-party imports
28import requests
30# Local imports
31from .._version import __version__
32from ..shared.exceptions import UploadError
33from ..utils import UploaderUtils
34from .base_uploader import BaseUploader
36# ///////////////////////////////////////////////////////////////
37# CONSTANTS
38# ///////////////////////////////////////////////////////////////
40# Explicit config wins; the environment fills empty credentials only.
41_CREDENTIAL_ENV_VARS: dict[str, str] = {
42 "username": "EZCOMPILER_SERVER_USERNAME",
43 "password": "EZCOMPILER_SERVER_PASSWORD", # nosec B105 - env var name
44 "api_key": "EZCOMPILER_SERVER_API_KEY",
45}
47# ///////////////////////////////////////////////////////////////
48# CLASSES
49# ///////////////////////////////////////////////////////////////
52class ServerUploader(BaseUploader):
53 """
54 Uploader for server operations via HTTP/HTTPS.
56 Handles uploading files to remote servers using HTTP POST requests with
57 support for authentication, SSL verification, and automatic retry logic.
59 Configuration keys:
60 server_url (str): Base URL of the upload server
61 username (str): Username for basic authentication (default: "")
62 password (str): Password for basic authentication (default: "")
63 api_key (str): API key for bearer token authentication (default: "")
64 Empty ``username``, ``password`` and ``api_key`` fall back to the
65 ``EZCOMPILER_SERVER_USERNAME``, ``EZCOMPILER_SERVER_PASSWORD`` and
66 ``EZCOMPILER_SERVER_API_KEY`` environment variables, so the CLI
67 never needs a credential on its command line.
68 timeout (int|float): Request timeout in seconds (default: 30)
69 verify_ssl (bool): Verify SSL certificates (default: True)
70 chunk_size (int): Chunk size for uploads (default: 8192)
71 retry_attempts (int): Number of retry attempts (default: 3)
72 proxies (dict): Proxy URLs keyed by scheme, e.g.
73 ``{"http": "http://proxy:3128", "https": "http://proxy:3128"}`` (default: {})
74 extra_headers (dict): Additional HTTP headers merged into every request (default: {})
75 cert (str | tuple | None): Client certificate for mTLS — path to a .pem file,
76 or a ``(certfile, keyfile)`` tuple (default: None)
78 Example:
79 >>> config = {"server_url": "https://example.com", "api_key": "abc123"}
80 >>> uploader = ServerUploader(config)
81 >>> uploader.upload(Path("file.zip"), "/uploads/file.zip")
82 """
84 # ////////////////////////////////////////////////
85 # INITIALIZATION
86 # ////////////////////////////////////////////////
88 def __init__(self, config: dict[str, Any] | None = None) -> None:
89 """
90 Initialize the server uploader.
92 Args:
93 config: Optional configuration dictionary with server settings
94 """
95 default_config = UploaderUtils.get_default_server_config()
96 UploaderUtils.reject_unknown_config_keys(
97 config, default_config, uploader="server"
98 )
100 if config: 100 ↛ 103line 100 didn't jump to line 103 because the condition on line 100 was always true
101 default_config.update(config)
103 for key, env_var in _CREDENTIAL_ENV_VARS.items():
104 if not default_config.get(key):
105 default_config[key] = os.environ.get(env_var, "")
107 super().__init__(default_config)
109 # ////////////////////////////////////////////////
110 # PUBLIC METHODS
111 # ////////////////////////////////////////////////
113 def get_uploader_name(self) -> str:
114 """
115 Get the name of this uploader.
117 Returns:
118 str: Name of the uploader
119 """
120 return "Server Uploader"
122 def upload(self, source_path: Path, destination: str) -> None:
123 """
124 Upload a file or a directory tree to a remote server.
126 For directories, walks recursively and POSTs each file using its
127 POSIX path relative to ``source_path`` as the server destination.
129 Args:
130 source_path: Path to the source file or directory
131 destination: Destination path on the server (single-file uploads)
133 Raises:
134 UploadError: If any file fails after all retry attempts.
136 Note:
137 Automatically retries on failure based on retry_attempts config.
138 """
139 try:
140 self._validate_source_path(source_path)
142 if source_path.is_dir():
143 for file_path in sorted(source_path.rglob("*")):
144 if file_path.is_file():
145 rel = file_path.relative_to(source_path).as_posix()
146 self._upload_single_file_with_retry(file_path, rel)
147 return
149 self._upload_single_file_with_retry(source_path, destination)
151 except UploadError:
152 raise
153 except Exception as e:
154 raise UploadError(f"Server upload failed: {e}") from e
156 def download(self, remote_source: str, local_dir: Path) -> None:
157 """
158 Fetch the current TUF tree from ``remote_source`` (metadata-driven).
160 Reads the role metadata by known names, then the targets listed in
161 ``targets.json``. A 404 on ``timestamp.json`` means the channel is
162 empty (first run) -> no-op.
164 Args:
165 remote_source: Base read URL of the update channel.
166 local_dir: Local directory to populate with the TUF tree.
168 Raises:
169 UploadError: On any non-404 transport failure.
170 """
171 import json # noqa: PLC0415
173 base = remote_source.rstrip("/")
174 try:
175 ts = self._get(f"{base}/metadata/timestamp.json")
176 if ts is None:
177 return # premier run : canal vide
178 self._save(local_dir / "metadata" / "timestamp.json", ts)
180 for role in ("snapshot.json", "root.json", "targets.json"):
181 body = self._get(f"{base}/metadata/{role}")
182 if body is not None: 182 ↛ 180line 182 didn't jump to line 180 because the condition on line 182 was always true
183 self._save(local_dir / "metadata" / role, body)
185 targets_path = local_dir / "metadata" / "targets.json"
186 if targets_path.exists(): 186 ↛ exitline 186 didn't return from function 'download' because the condition on line 186 was always true
187 doc = json.loads(targets_path.read_text())
188 targets_root = local_dir / "targets"
189 for name in doc.get("signed", {}).get("targets", {}):
190 dest = self._safe_join(targets_root, name)
191 body = self._get(f"{base}/targets/{name}")
192 if body is not None: 192 ↛ 189line 192 didn't jump to line 189 because the condition on line 192 was always true
193 self._save(dest, body)
194 except UploadError:
195 raise
196 except Exception as e:
197 raise UploadError(f"Server download failed: {e}") from e
199 def _get(self, url: str) -> bytes | None:
200 """GET ``url``; return bytes, or None on 404.
202 Raises:
203 UploadError: On any non-404 error response.
204 """
205 response = requests.get( # nosec B113 - timeout supplied and validated > 0 in _validate_config
206 url,
207 headers=self._prepare_headers(),
208 auth=self._prepare_auth(),
209 timeout=self._config["timeout"],
210 verify=self._config["verify_ssl"],
211 proxies=self._config["proxies"] or None,
212 cert=self._config["cert"],
213 )
214 if response.status_code == 404:
215 return None
216 if not response.ok: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 raise UploadError(f"Server returned error {response.status_code} for {url}")
218 return response.content
220 @staticmethod
221 def _safe_join(root: Path, name: str) -> Path:
222 """Join ``name`` under ``root``, rejecting path traversal.
224 Target names come from a remote, not-yet-verified ``targets.json``;
225 a crafted ``name`` (``../`` or absolute) must never escape ``root``.
227 Raises:
228 UploadError: If ``name`` resolves outside ``root``.
229 """
230 root_resolved = root.resolve()
231 candidate = (root / name).resolve()
232 if root_resolved != candidate and root_resolved not in candidate.parents:
233 raise UploadError(f"Unsafe target name rejected: {name}")
234 return candidate
236 @staticmethod
237 def _save(path: Path, content: bytes) -> None:
238 """Write ``content`` to ``path``, creating parent directories."""
239 path.parent.mkdir(parents=True, exist_ok=True)
240 path.write_bytes(content)
242 def _upload_single_file_with_retry(
243 self, source_path: Path, destination: str
244 ) -> None:
245 """POST a single file, retrying per ``retry_attempts``.
247 Raises:
248 UploadError: If the file fails after all retry attempts.
249 """
250 last_error = None
251 for attempt in range(self._config["retry_attempts"]): 251 ↛ 259line 251 didn't jump to line 259 because the loop on line 251 didn't complete
252 try:
253 self._perform_upload(source_path, destination)
254 return
255 except Exception as e:
256 last_error = e
257 if attempt == self._config["retry_attempts"] - 1:
258 break
259 raise UploadError(
260 f"Server upload failed after {self._config['retry_attempts']} "
261 f"attempts: {last_error}"
262 ) from last_error
264 def _test_connection(self) -> bool:
265 """
266 Test the connection to the server.
268 Returns:
269 bool: True if connection is successful, False otherwise
271 Note:
272 Attempts to reach /health endpoint on the server.
273 """
274 try:
275 test_url = f"{self._config['server_url'].rstrip('/')}/health"
276 headers = self._prepare_headers()
277 auth = self._prepare_auth()
279 response = requests.get( # nosec B113 - timeout supplied and validated > 0 in _validate_config
280 test_url,
281 headers=headers,
282 auth=auth,
283 timeout=self._config["timeout"],
284 verify=self._config["verify_ssl"],
285 proxies=self._config["proxies"] or None,
286 cert=self._config["cert"],
287 )
289 return response.ok
290 except Exception:
291 return False
293 # ////////////////////////////////////////////////
294 # PRIVATE METHODS
295 # ////////////////////////////////////////////////
297 def _perform_upload(self, source_path: Path, destination: str) -> None:
298 """
299 Perform the actual upload operation.
301 Args:
302 source_path: Source file path
303 destination: Destination path on server
305 Raises:
306 UploadError: If server returns error response
307 """
308 upload_url = self._build_upload_url(destination)
309 headers = self._prepare_headers()
310 auth = self._prepare_auth()
312 with open(source_path, "rb") as file:
313 files = {"file": (source_path.name, file, "application/octet-stream")}
314 data = {"destination": destination}
316 response = requests.post( # nosec B113 - timeout supplied and validated > 0 in _validate_config
317 upload_url,
318 files=files,
319 data=data,
320 headers=headers,
321 auth=auth,
322 timeout=self._config["timeout"],
323 verify=self._config["verify_ssl"],
324 proxies=self._config["proxies"] or None,
325 cert=self._config["cert"],
326 )
328 if not response.ok:
329 # Never response.text: a misconfigured or hostile endpoint
330 # would echo the received headers, hence Authorization, into the logs.
331 raise UploadError(
332 f"Server returned error {response.status_code} "
333 f"({response.reason}) for {source_path.name}."
334 )
336 def _build_upload_url(self, _destination: str) -> str:
337 """
338 Build the complete upload URL.
340 Args:
341 _destination: Destination path (unused, for future extensions)
343 Returns:
344 str: Complete upload URL
345 """
346 base_url = self._config["server_url"].rstrip("/")
347 return f"{base_url}/upload"
349 def _prepare_headers(self) -> dict[str, str]:
350 """
351 Prepare HTTP headers for the upload request.
353 Returns:
354 dict[str, str]: Headers dictionary
356 Note:
357 Includes User-Agent and optional Bearer token authorization.
358 """
359 headers = {
360 "User-Agent": f"EzCompiler/{__version__}",
361 "Accept": "application/json",
362 }
364 if self._config["api_key"]:
365 headers["Authorization"] = f"Bearer {self._config['api_key']}"
367 headers.update(self._config.get("extra_headers", {}))
368 return headers
370 def _prepare_auth(self) -> tuple[str, str] | None:
371 """
372 Prepare authentication for the upload request.
374 Returns:
375 tuple[str, str] | None: Basic auth tuple or None
377 Note:
378 Returns (username, password) tuple for basic auth if configured.
379 """
380 if self._config["username"] and self._config["password"]:
381 return (self._config["username"], self._config["password"])
382 return None
384 # ////////////////////////////////////////////////
385 # VALIDATION METHODS
386 # ////////////////////////////////////////////////
388 def _validate_config(self) -> None:
389 """
390 Validate server uploader configuration.
392 Raises:
393 UploadError: If configuration is invalid
395 Note:
396 Validates required keys, URL format, and value types/ranges.
397 Uses UploaderUtils for URL validation.
398 """
399 required_keys = [
400 "server_url",
401 "username",
402 "password",
403 "api_key",
404 "timeout",
405 "verify_ssl",
406 "chunk_size",
407 "retry_attempts",
408 "proxies",
409 "extra_headers",
410 "cert",
411 ]
413 for key in required_keys:
414 if key not in self._config: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true
415 raise UploadError(f"Missing required configuration key: {key}")
417 # Validate server URL using UploaderUtils
418 UploaderUtils.validate_server_url(self._config["server_url"])
420 if ( 420 ↛ 424line 420 didn't jump to line 424 because the condition on line 420 was never true
421 not isinstance(self._config["timeout"], (int, float))
422 or self._config["timeout"] <= 0
423 ):
424 raise UploadError("timeout must be a positive number")
426 if ( 426 ↛ 430line 426 didn't jump to line 430 because the condition on line 426 was never true
427 not isinstance(self._config["chunk_size"], int)
428 or self._config["chunk_size"] <= 0
429 ):
430 raise UploadError("chunk_size must be a positive integer")
432 if (
433 not isinstance(self._config["retry_attempts"], int)
434 or self._config["retry_attempts"] < 1
435 ):
436 raise UploadError("retry_attempts must be an integer >= 1")
438 if not isinstance(self._config["verify_ssl"], bool): 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true
439 raise UploadError("verify_ssl must be a boolean")
441 if not isinstance(self._config["proxies"], dict): 441 ↛ 442line 441 didn't jump to line 442 because the condition on line 441 was never true
442 raise UploadError("proxies must be a dict")
444 if not isinstance(self._config["extra_headers"], dict): 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true
445 raise UploadError("extra_headers must be a dict")
447 cert = self._config["cert"]
448 if cert is not None and not isinstance(cert, (str, tuple)): 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 raise UploadError(
450 "cert must be a path string or a (certfile, keyfile) tuple"
451 )