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

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 

23from pathlib import Path 

24from typing import Any 

25 

26# Third-party imports 

27import requests 

28 

29# Local imports 

30from .._version import __version__ 

31from ..shared.exceptions import UploadError 

32from ..utils import UploaderUtils 

33from .base_uploader import BaseUploader 

34 

35# /////////////////////////////////////////////////////////////// 

36# CLASSES 

37# /////////////////////////////////////////////////////////////// 

38 

39 

40class ServerUploader(BaseUploader): 

41 """ 

42 Uploader for server operations via HTTP/HTTPS. 

43 

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

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

46 

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) 

61 

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

67 

68 # //////////////////////////////////////////////// 

69 # INITIALIZATION 

70 # //////////////////////////////////////////////// 

71 

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

73 """ 

74 Initialize the server uploader. 

75 

76 Args: 

77 config: Optional configuration dictionary with server settings 

78 """ 

79 default_config = UploaderUtils.get_default_server_config() 

80 

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) 

83 

84 super().__init__(default_config) 

85 

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

87 # PUBLIC METHODS 

88 # //////////////////////////////////////////////// 

89 

90 def get_uploader_name(self) -> str: 

91 """ 

92 Get the name of this uploader. 

93 

94 Returns: 

95 str: Name of the uploader 

96 """ 

97 return "Server Uploader" 

98 

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

100 """ 

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

102 

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

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

105 

106 Args: 

107 source_path: Path to the source file or directory 

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

109 

110 Raises: 

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

112 

113 Note: 

114 Automatically retries on failure based on retry_attempts config. 

115 """ 

116 try: 

117 self._validate_source_path(source_path) 

118 

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 

125 

126 self._upload_single_file_with_retry(source_path, destination) 

127 

128 except UploadError: 

129 raise 

130 except Exception as e: 

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

132 

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

134 """ 

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

136 

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. 

140 

141 Args: 

142 remote_source: Base read URL of the update channel. 

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

144 

145 Raises: 

146 UploadError: On any non-404 transport failure. 

147 """ 

148 import json # noqa: PLC0415 

149 

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) 

156 

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) 

161 

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 

175 

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

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

178 

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 

196 

197 @staticmethod 

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

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

200 

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

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

203 

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 

212 

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) 

218 

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

223 

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 

240 

241 def _test_connection(self) -> bool: 

242 """ 

243 Test the connection to the server. 

244 

245 Returns: 

246 bool: True if connection is successful, False otherwise 

247 

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

255 

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 ) 

265 

266 return response.ok 

267 except Exception: 

268 return False 

269 

270 # //////////////////////////////////////////////// 

271 # PRIVATE METHODS 

272 # //////////////////////////////////////////////// 

273 

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

275 """ 

276 Perform the actual upload operation. 

277 

278 Args: 

279 source_path: Source file path 

280 destination: Destination path on server 

281 

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

288 

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

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

291 data = {"destination": destination} 

292 

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 ) 

304 

305 if not response.ok: 

306 raise UploadError( 

307 f"Server returned error {response.status_code}: {response.text}" 

308 ) 

309 

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

311 """ 

312 Build the complete upload URL. 

313 

314 Args: 

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

316 

317 Returns: 

318 str: Complete upload URL 

319 """ 

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

321 return f"{base_url}/upload" 

322 

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

324 """ 

325 Prepare HTTP headers for the upload request. 

326 

327 Returns: 

328 dict[str, str]: Headers dictionary 

329 

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 } 

337 

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']}" 

340 

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

342 return headers 

343 

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

345 """ 

346 Prepare authentication for the upload request. 

347 

348 Returns: 

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

350 

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 

357 

358 # //////////////////////////////////////////////// 

359 # VALIDATION METHODS 

360 # //////////////////////////////////////////////// 

361 

362 def _validate_config(self) -> None: 

363 """ 

364 Validate server uploader configuration. 

365 

366 Raises: 

367 UploadError: If configuration is invalid 

368 

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 ] 

386 

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

390 

391 # Validate server URL using UploaderUtils 

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

393 

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

399 

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

405 

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

411 

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

414 

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

417 

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

420 

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 )