Coverage for src/ezcompiler/adapters/_server_uploader.py: 69.11%
137 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# 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
23from pathlib import Path
24from typing import Any
26# Third-party imports
27import requests
29# Local imports
30from .._version import __version__
31from ..shared.exceptions import UploadError
32from ..utils import UploaderUtils
33from .base_uploader import BaseUploader
35# ///////////////////////////////////////////////////////////////
36# CLASSES
37# ///////////////////////////////////////////////////////////////
40class ServerUploader(BaseUploader):
41 """
42 Uploader for server operations via HTTP/HTTPS.
44 Handles uploading files to remote servers using HTTP POST requests with
45 support for authentication, SSL verification, and automatic retry logic.
47 Configuration keys:
48 server_url (str): Base URL of the upload server
49 username (str): Username for basic authentication (default: "")
50 password (str): Password for basic authentication (default: "")
51 api_key (str): API key for bearer token authentication (default: "")
52 timeout (int|float): Request timeout in seconds (default: 30)
53 verify_ssl (bool): Verify SSL certificates (default: True)
54 chunk_size (int): Chunk size for uploads (default: 8192)
55 retry_attempts (int): Number of retry attempts (default: 3)
56 proxies (dict): Proxy URLs keyed by scheme, e.g.
57 ``{"http": "http://proxy:3128", "https": "http://proxy:3128"}`` (default: {})
58 extra_headers (dict): Additional HTTP headers merged into every request (default: {})
59 cert (str | tuple | None): Client certificate for mTLS — path to a .pem file,
60 or a ``(certfile, keyfile)`` tuple (default: None)
62 Example:
63 >>> config = {"server_url": "https://example.com", "api_key": "abc123"}
64 >>> uploader = ServerUploader(config)
65 >>> uploader.upload(Path("file.zip"), "/uploads/file.zip")
66 """
68 # ////////////////////////////////////////////////
69 # INITIALIZATION
70 # ////////////////////////////////////////////////
72 def __init__(self, config: dict[str, Any] | None = None) -> None:
73 """
74 Initialize the server uploader.
76 Args:
77 config: Optional configuration dictionary with server settings
78 """
79 default_config = UploaderUtils.get_default_server_config()
81 if config: 81 ↛ 84line 81 didn't jump to line 84 because the condition on line 81 was always true
82 default_config.update(config)
84 super().__init__(default_config)
86 # ////////////////////////////////////////////////
87 # PUBLIC METHODS
88 # ////////////////////////////////////////////////
90 def get_uploader_name(self) -> str:
91 """
92 Get the name of this uploader.
94 Returns:
95 str: Name of the uploader
96 """
97 return "Server Uploader"
99 def upload(self, source_path: Path, destination: str) -> None:
100 """
101 Upload a file or a directory tree to a remote server.
103 For directories, walks recursively and POSTs each file using its
104 POSIX path relative to ``source_path`` as the server destination.
106 Args:
107 source_path: Path to the source file or directory
108 destination: Destination path on the server (single-file uploads)
110 Raises:
111 UploadError: If any file fails after all retry attempts.
113 Note:
114 Automatically retries on failure based on retry_attempts config.
115 """
116 try:
117 self._validate_source_path(source_path)
119 if source_path.is_dir():
120 for file_path in sorted(source_path.rglob("*")):
121 if file_path.is_file():
122 rel = file_path.relative_to(source_path).as_posix()
123 self._upload_single_file_with_retry(file_path, rel)
124 return
126 self._upload_single_file_with_retry(source_path, destination)
128 except UploadError:
129 raise
130 except Exception as e:
131 raise UploadError(f"Server upload failed: {e}") from e
133 def download(self, remote_source: str, local_dir: Path) -> None:
134 """
135 Fetch the current TUF tree from ``remote_source`` (metadata-driven).
137 Reads the role metadata by known names, then the targets listed in
138 ``targets.json``. A 404 on ``timestamp.json`` means the channel is
139 empty (first run) -> no-op.
141 Args:
142 remote_source: Base read URL of the update channel.
143 local_dir: Local directory to populate with the TUF tree.
145 Raises:
146 UploadError: On any non-404 transport failure.
147 """
148 import json # noqa: PLC0415
150 base = remote_source.rstrip("/")
151 try:
152 ts = self._get(f"{base}/metadata/timestamp.json")
153 if ts is None:
154 return # premier run : canal vide
155 self._save(local_dir / "metadata" / "timestamp.json", ts)
157 for role in ("snapshot.json", "root.json", "targets.json"):
158 body = self._get(f"{base}/metadata/{role}")
159 if body is not None: 159 ↛ 157line 159 didn't jump to line 157 because the condition on line 159 was always true
160 self._save(local_dir / "metadata" / role, body)
162 targets_path = local_dir / "metadata" / "targets.json"
163 if targets_path.exists(): 163 ↛ exitline 163 didn't return from function 'download' because the condition on line 163 was always true
164 doc = json.loads(targets_path.read_text())
165 targets_root = local_dir / "targets"
166 for name in doc.get("signed", {}).get("targets", {}):
167 dest = self._safe_join(targets_root, name)
168 body = self._get(f"{base}/targets/{name}")
169 if body is not None: 169 ↛ 166line 169 didn't jump to line 166 because the condition on line 169 was always true
170 self._save(dest, body)
171 except UploadError:
172 raise
173 except Exception as e:
174 raise UploadError(f"Server download failed: {e}") from e
176 def _get(self, url: str) -> bytes | None:
177 """GET ``url``; return bytes, or None on 404.
179 Raises:
180 UploadError: On any non-404 error response.
181 """
182 response = requests.get( # nosec B113 - timeout fourni et validé > 0 dans _validate_config
183 url,
184 headers=self._prepare_headers(),
185 auth=self._prepare_auth(),
186 timeout=self._config["timeout"],
187 verify=self._config["verify_ssl"],
188 proxies=self._config["proxies"] or None,
189 cert=self._config["cert"],
190 )
191 if response.status_code == 404:
192 return None
193 if not response.ok: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 raise UploadError(f"Server returned error {response.status_code} for {url}")
195 return response.content
197 @staticmethod
198 def _safe_join(root: Path, name: str) -> Path:
199 """Join ``name`` under ``root``, rejecting path traversal.
201 Target names come from a remote, not-yet-verified ``targets.json``;
202 a crafted ``name`` (``../`` or absolute) must never escape ``root``.
204 Raises:
205 UploadError: If ``name`` resolves outside ``root``.
206 """
207 root_resolved = root.resolve()
208 candidate = (root / name).resolve()
209 if root_resolved != candidate and root_resolved not in candidate.parents:
210 raise UploadError(f"Unsafe target name rejected: {name}")
211 return candidate
213 @staticmethod
214 def _save(path: Path, content: bytes) -> None:
215 """Write ``content`` to ``path``, creating parent directories."""
216 path.parent.mkdir(parents=True, exist_ok=True)
217 path.write_bytes(content)
219 def _upload_single_file_with_retry(
220 self, source_path: Path, destination: str
221 ) -> None:
222 """POST a single file, retrying per ``retry_attempts``.
224 Raises:
225 UploadError: If the file fails after all retry attempts.
226 """
227 last_error = None
228 for attempt in range(self._config["retry_attempts"]): 228 ↛ 236line 228 didn't jump to line 236 because the loop on line 228 didn't complete
229 try:
230 self._perform_upload(source_path, destination)
231 return
232 except Exception as e:
233 last_error = e
234 if attempt == self._config["retry_attempts"] - 1:
235 break
236 raise UploadError(
237 f"Server upload failed after {self._config['retry_attempts']} "
238 f"attempts: {last_error}"
239 ) from last_error
241 def _test_connection(self) -> bool:
242 """
243 Test the connection to the server.
245 Returns:
246 bool: True if connection is successful, False otherwise
248 Note:
249 Attempts to reach /health endpoint on the server.
250 """
251 try:
252 test_url = f"{self._config['server_url'].rstrip('/')}/health"
253 headers = self._prepare_headers()
254 auth = self._prepare_auth()
256 response = requests.get( # nosec B113 - timeout fourni et validé > 0 dans _validate_config
257 test_url,
258 headers=headers,
259 auth=auth,
260 timeout=self._config["timeout"],
261 verify=self._config["verify_ssl"],
262 proxies=self._config["proxies"] or None,
263 cert=self._config["cert"],
264 )
266 return response.ok
267 except Exception:
268 return False
270 # ////////////////////////////////////////////////
271 # PRIVATE METHODS
272 # ////////////////////////////////////////////////
274 def _perform_upload(self, source_path: Path, destination: str) -> None:
275 """
276 Perform the actual upload operation.
278 Args:
279 source_path: Source file path
280 destination: Destination path on server
282 Raises:
283 UploadError: If server returns error response
284 """
285 upload_url = self._build_upload_url(destination)
286 headers = self._prepare_headers()
287 auth = self._prepare_auth()
289 with open(source_path, "rb") as file:
290 files = {"file": (source_path.name, file, "application/octet-stream")}
291 data = {"destination": destination}
293 response = requests.post( # nosec B113 - timeout fourni et validé > 0 dans _validate_config
294 upload_url,
295 files=files,
296 data=data,
297 headers=headers,
298 auth=auth,
299 timeout=self._config["timeout"],
300 verify=self._config["verify_ssl"],
301 proxies=self._config["proxies"] or None,
302 cert=self._config["cert"],
303 )
305 if not response.ok:
306 raise UploadError(
307 f"Server returned error {response.status_code}: {response.text}"
308 )
310 def _build_upload_url(self, _destination: str) -> str:
311 """
312 Build the complete upload URL.
314 Args:
315 _destination: Destination path (unused, for future extensions)
317 Returns:
318 str: Complete upload URL
319 """
320 base_url = self._config["server_url"].rstrip("/")
321 return f"{base_url}/upload"
323 def _prepare_headers(self) -> dict[str, str]:
324 """
325 Prepare HTTP headers for the upload request.
327 Returns:
328 dict[str, str]: Headers dictionary
330 Note:
331 Includes User-Agent and optional Bearer token authorization.
332 """
333 headers = {
334 "User-Agent": f"EzCompiler/{__version__}",
335 "Accept": "application/json",
336 }
338 if self._config["api_key"]: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true
339 headers["Authorization"] = f"Bearer {self._config['api_key']}"
341 headers.update(self._config.get("extra_headers", {}))
342 return headers
344 def _prepare_auth(self) -> tuple[str, str] | None:
345 """
346 Prepare authentication for the upload request.
348 Returns:
349 tuple[str, str] | None: Basic auth tuple or None
351 Note:
352 Returns (username, password) tuple for basic auth if configured.
353 """
354 if self._config["username"] and self._config["password"]: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 return (self._config["username"], self._config["password"])
356 return None
358 # ////////////////////////////////////////////////
359 # VALIDATION METHODS
360 # ////////////////////////////////////////////////
362 def _validate_config(self) -> None:
363 """
364 Validate server uploader configuration.
366 Raises:
367 UploadError: If configuration is invalid
369 Note:
370 Validates required keys, URL format, and value types/ranges.
371 Uses UploaderUtils for URL validation.
372 """
373 required_keys = [
374 "server_url",
375 "username",
376 "password",
377 "api_key",
378 "timeout",
379 "verify_ssl",
380 "chunk_size",
381 "retry_attempts",
382 "proxies",
383 "extra_headers",
384 "cert",
385 ]
387 for key in required_keys:
388 if key not in self._config: 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true
389 raise UploadError(f"Missing required configuration key: {key}")
391 # Validate server URL using UploaderUtils
392 UploaderUtils.validate_server_url(self._config["server_url"])
394 if ( 394 ↛ 398line 394 didn't jump to line 398 because the condition on line 394 was never true
395 not isinstance(self._config["timeout"], (int, float))
396 or self._config["timeout"] <= 0
397 ):
398 raise UploadError("timeout must be a positive number")
400 if ( 400 ↛ 404line 400 didn't jump to line 404 because the condition on line 400 was never true
401 not isinstance(self._config["chunk_size"], int)
402 or self._config["chunk_size"] <= 0
403 ):
404 raise UploadError("chunk_size must be a positive integer")
406 if (
407 not isinstance(self._config["retry_attempts"], int)
408 or self._config["retry_attempts"] < 1
409 ):
410 raise UploadError("retry_attempts must be an integer >= 1")
412 if not isinstance(self._config["verify_ssl"], bool): 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true
413 raise UploadError("verify_ssl must be a boolean")
415 if not isinstance(self._config["proxies"], dict): 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true
416 raise UploadError("proxies must be a dict")
418 if not isinstance(self._config["extra_headers"], dict): 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true
419 raise UploadError("extra_headers must be a dict")
421 cert = self._config["cert"]
422 if cert is not None and not isinstance(cert, (str, tuple)): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true
423 raise UploadError(
424 "cert must be a path string or a (certfile, keyfile) tuple"
425 )