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

1# /////////////////////////////////////////////////////////////// 

2# SERVER_UPLOADER - Remote server uploader implementation 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Server uploader - HTTP/HTTPS remote server upload handler for EzCompiler. 

8 

9This module provides functionality for uploading files to remote servers 

10via HTTP/HTTPS POST requests, with support for authentication, retry logic, 

11and SSL verification. 

12 

13Note: Protocols layer should not perform logging directly. Logging is handled 

14by the service layer that orchestrates upload operations. 

15""" 

16 

17from __future__ import annotations 

18 

19# /////////////////////////////////////////////////////////////// 

20# IMPORTS 

21# /////////////////////////////////////////////////////////////// 

22# Standard library imports 

23import os 

24from pathlib import Path 

25from typing import Any 

26 

27# Third-party imports 

28import requests 

29 

30# Local imports 

31from .._version import __version__ 

32from ..shared.exceptions import UploadError 

33from ..utils import UploaderUtils 

34from .base_uploader import BaseUploader 

35 

36# /////////////////////////////////////////////////////////////// 

37# CONSTANTS 

38# /////////////////////////////////////////////////////////////// 

39 

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} 

46 

47# /////////////////////////////////////////////////////////////// 

48# CLASSES 

49# /////////////////////////////////////////////////////////////// 

50 

51 

52class ServerUploader(BaseUploader): 

53 """ 

54 Uploader for server operations via HTTP/HTTPS. 

55 

56 Handles uploading files to remote servers using HTTP POST requests with 

57 support for authentication, SSL verification, and automatic retry logic. 

58 

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) 

77 

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 """ 

83 

84 # //////////////////////////////////////////////// 

85 # INITIALIZATION 

86 # //////////////////////////////////////////////// 

87 

88 def __init__(self, config: dict[str, Any] | None = None) -> None: 

89 """ 

90 Initialize the server uploader. 

91 

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 ) 

99 

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) 

102 

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, "") 

106 

107 super().__init__(default_config) 

108 

109 # //////////////////////////////////////////////// 

110 # PUBLIC METHODS 

111 # //////////////////////////////////////////////// 

112 

113 def get_uploader_name(self) -> str: 

114 """ 

115 Get the name of this uploader. 

116 

117 Returns: 

118 str: Name of the uploader 

119 """ 

120 return "Server Uploader" 

121 

122 def upload(self, source_path: Path, destination: str) -> None: 

123 """ 

124 Upload a file or a directory tree to a remote server. 

125 

126 For directories, walks recursively and POSTs each file using its 

127 POSIX path relative to ``source_path`` as the server destination. 

128 

129 Args: 

130 source_path: Path to the source file or directory 

131 destination: Destination path on the server (single-file uploads) 

132 

133 Raises: 

134 UploadError: If any file fails after all retry attempts. 

135 

136 Note: 

137 Automatically retries on failure based on retry_attempts config. 

138 """ 

139 try: 

140 self._validate_source_path(source_path) 

141 

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 

148 

149 self._upload_single_file_with_retry(source_path, destination) 

150 

151 except UploadError: 

152 raise 

153 except Exception as e: 

154 raise UploadError(f"Server upload failed: {e}") from e 

155 

156 def download(self, remote_source: str, local_dir: Path) -> None: 

157 """ 

158 Fetch the current TUF tree from ``remote_source`` (metadata-driven). 

159 

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. 

163 

164 Args: 

165 remote_source: Base read URL of the update channel. 

166 local_dir: Local directory to populate with the TUF tree. 

167 

168 Raises: 

169 UploadError: On any non-404 transport failure. 

170 """ 

171 import json # noqa: PLC0415 

172 

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) 

179 

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) 

184 

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 

198 

199 def _get(self, url: str) -> bytes | None: 

200 """GET ``url``; return bytes, or None on 404. 

201 

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 

219 

220 @staticmethod 

221 def _safe_join(root: Path, name: str) -> Path: 

222 """Join ``name`` under ``root``, rejecting path traversal. 

223 

224 Target names come from a remote, not-yet-verified ``targets.json``; 

225 a crafted ``name`` (``../`` or absolute) must never escape ``root``. 

226 

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 

235 

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) 

241 

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``. 

246 

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 

263 

264 def _test_connection(self) -> bool: 

265 """ 

266 Test the connection to the server. 

267 

268 Returns: 

269 bool: True if connection is successful, False otherwise 

270 

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() 

278 

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 ) 

288 

289 return response.ok 

290 except Exception: 

291 return False 

292 

293 # //////////////////////////////////////////////// 

294 # PRIVATE METHODS 

295 # //////////////////////////////////////////////// 

296 

297 def _perform_upload(self, source_path: Path, destination: str) -> None: 

298 """ 

299 Perform the actual upload operation. 

300 

301 Args: 

302 source_path: Source file path 

303 destination: Destination path on server 

304 

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() 

311 

312 with open(source_path, "rb") as file: 

313 files = {"file": (source_path.name, file, "application/octet-stream")} 

314 data = {"destination": destination} 

315 

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 ) 

327 

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 ) 

335 

336 def _build_upload_url(self, _destination: str) -> str: 

337 """ 

338 Build the complete upload URL. 

339 

340 Args: 

341 _destination: Destination path (unused, for future extensions) 

342 

343 Returns: 

344 str: Complete upload URL 

345 """ 

346 base_url = self._config["server_url"].rstrip("/") 

347 return f"{base_url}/upload" 

348 

349 def _prepare_headers(self) -> dict[str, str]: 

350 """ 

351 Prepare HTTP headers for the upload request. 

352 

353 Returns: 

354 dict[str, str]: Headers dictionary 

355 

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 } 

363 

364 if self._config["api_key"]: 

365 headers["Authorization"] = f"Bearer {self._config['api_key']}" 

366 

367 headers.update(self._config.get("extra_headers", {})) 

368 return headers 

369 

370 def _prepare_auth(self) -> tuple[str, str] | None: 

371 """ 

372 Prepare authentication for the upload request. 

373 

374 Returns: 

375 tuple[str, str] | None: Basic auth tuple or None 

376 

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 

383 

384 # //////////////////////////////////////////////// 

385 # VALIDATION METHODS 

386 # //////////////////////////////////////////////// 

387 

388 def _validate_config(self) -> None: 

389 """ 

390 Validate server uploader configuration. 

391 

392 Raises: 

393 UploadError: If configuration is invalid 

394 

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 ] 

412 

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}") 

416 

417 # Validate server URL using UploaderUtils 

418 UploaderUtils.validate_server_url(self._config["server_url"]) 

419 

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") 

425 

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") 

431 

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") 

437 

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") 

440 

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") 

443 

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") 

446 

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 )