using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using Polly; using Polly.Retry; using PSLauncher.Core.Configuration; using PSLauncher.Core.Integrity; using PSLauncher.Models; namespace PSLauncher.Core.Downloads; /// /// Téléchargement HTTP avec : /// - reprise par Range requests (If-Range sur ETag/Last-Modified) ; /// - retry exponentiel via Polly (1-32 s, 6 tentatives, jitter) ; /// - téléchargement parallèle multi-segments (config ) ; /// - vérif SHA-256 streaming en fin de DL ; /// - persistance d'état (state.json) toutes les 5 s ou 100 Mo pour reprise après crash. /// /// Le multi-segments contourne le throttling per-connection d'Apache (notamment OVH mutualisé) /// : chaque segment ouvre une connexion TCP séparée et écrit à son offset dans le fichier /// pré-alloué via SetLength. Speed-up typique observé : ×4 à ×10. /// public sealed class DownloadManager : IDownloadManager { private const int BufferSize = 4 << 20; // 4 MiB par segment (avant 1 MiB) private const int StateFlushBytesInterval = 50 * 1024 * 1024; // 50 MiB private const double StateFlushSecondsInterval = 5.0; private const long MultiSegmentMinSize = 50 * 1024 * 1024; // < 50 MiB → single-segment private readonly HttpClient _http; private readonly IDownloadStateStore _stateStore; private readonly IIntegrityService _integrity; private readonly LocalConfig _config; private readonly ILogger _logger; private readonly ResiliencePipeline _retryPipeline; // URL active partagée entre tous les segments d'un DL en cours, et lock pour // refresher de façon coordonnée quand l'URL HMAC expire (TTL serveur = 6 h). // Plusieurs segments peuvent recevoir 403 simultanément — un seul doit faire // l'appel /api/download-url/. private Uri _currentUrl = new("about:blank"); private DateTime _lastUrlRefresh = DateTime.MinValue; private readonly SemaphoreSlim _urlRefreshLock = new(1, 1); public DownloadManager( HttpClient http, IDownloadStateStore stateStore, IIntegrityService integrity, LocalConfig config, ILogger logger) { _http = http; _stateStore = stateStore; _integrity = integrity; _config = config; _logger = logger; _retryPipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { // 10 tentatives × backoff 1→32s avec jitter ≈ ~3 min de retry budget // par segment. Couvre les outages PHP-FPM courts en multi-PC (où des // segments peuvent être tués mid-stream par request_terminate_timeout) // sans donner l'impression d'une boucle infinie en cas de panne réelle. MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Exponential, Delay = TimeSpan.FromSeconds(1), MaxDelay = TimeSpan.FromSeconds(32), UseJitter = true, ShouldHandle = new PredicateBuilder().Handle(ex => ex is HttpRequestException or IOException or TimeoutException || (ex is HttpResumableException re && re.IsTransient)), OnRetry = args => { _logger.LogWarning( "Download retry #{Attempt} after {Delay} : {Error}", args.AttemptNumber + 1, args.RetryDelay, args.Outcome.Exception?.Message); return ValueTask.CompletedTask; } }) .Build(); } public DownloadState? GetResumableState(string version) => _stateStore.Load(version); public void DiscardResumableState(string version) => _stateStore.Discard(version); public Task DownloadAsync( DownloadJob job, IProgress? progress, CancellationToken ct) => DownloadAsync(job, progress, null, ct); /// /// Récupère l'URL active. Si la dernière récup date de moins de 5 s, /// retourne celle en cache (évite que 8 segments en 403 simultanés /// déclenchent 8 requêtes /api/download-url/). Sinon refresh via le /// callback du job. Si pas de callback, retourne l'URL initiale du job. /// private async Task GetOrRefreshUrlAsync(DownloadJob job, bool forceRefresh, CancellationToken ct) { await _urlRefreshLock.WaitAsync(ct).ConfigureAwait(false); try { // Premier appel : initialise avec l'URL du job if (_currentUrl.Scheme == "about") { _currentUrl = job.Url; _lastUrlRefresh = DateTime.UtcNow; } // Si refresh forcé (un segment a vu 403), tente de fetcher une nouvelle URL. // Debounce 5 s pour fusionner les refresh en rafale des 8 segments. if (forceRefresh && (DateTime.UtcNow - _lastUrlRefresh).TotalSeconds >= 5 && job.RefreshUrlAsync is not null) { _logger.LogInformation("Refreshing signed URL for v{Version} (URL expired or 403)", job.Version); var fresh = await job.RefreshUrlAsync(ct).ConfigureAwait(false); if (fresh is not null) { _currentUrl = fresh; _lastUrlRefresh = DateTime.UtcNow; _logger.LogInformation("Signed URL refreshed for v{Version}", job.Version); } } return _currentUrl; } finally { _urlRefreshLock.Release(); } } public async Task DownloadAsync( DownloadJob job, IProgress? progress, IProgress? hashProgress, CancellationToken ct) { // Reset URL state pour ce job (DownloadManager est singleton DI) _currentUrl = job.Url; _lastUrlRefresh = DateTime.UtcNow; var partialPath = _stateStore.GetPartialPath(job.Version); var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip // 1. Récupère l'état existant. On compare la ressource par son SHA-256 (signé via le // manifest) plutôt que par URL : les URLs HMAC-signées changent à chaque appel // (?exp=…&sig=…), donc une comparaison stricte d'URL invaliderait toujours le resume. // On compare aussi le path (host+path sans query) pour détecter un changement de // serveur/fichier, mais on ignore la querystring. var existing = _stateStore.Load(job.Version); if (existing is not null) { bool shaMismatch = !string.IsNullOrEmpty(job.ExpectedSha256) && !string.IsNullOrEmpty(existing.ExpectedSha256) && !string.Equals(existing.ExpectedSha256, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase); bool pathMismatch = false; if (Uri.TryCreate(existing.Url, UriKind.Absolute, out var oldUri)) { pathMismatch = oldUri.Host != job.Url.Host || oldUri.AbsolutePath != job.Url.AbsolutePath; } if (shaMismatch || pathMismatch) { _logger.LogInformation("Cached state for {Version} mismatches new job (sha={Sha} path={Path}) — discarding", job.Version, shaMismatch, pathMismatch); _stateStore.Discard(job.Version); existing = null; } else { // Même ressource : on rafraîchit juste l'URL stockée (utile pour les futures requêtes // au sein de cette session, même si en pratique chaque segment requête via job.Url). existing.Url = job.Url.ToString(); } } // 2. Choix single vs multi-segment // Hard-cap à 32 : au-delà OVH mutualisé risque le rate-limit per-IP et le // gain marginal devient négatif. 16 = défaut, 24-32 OK pour les serveurs // qui le supportent. 1 = comportement single-segment legacy. var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 32); var useMultiSegment = requestedSegments > 1 && job.ExpectedSize >= MultiSegmentMinSize && (existing is null || existing.Segments.Count > 0); // Si l'état existant est en mode legacy (single-segment) et qu'on veut multi → on conserve le legacy // pour pouvoir reprendre proprement (sinon on jetterait son DL). if (existing is { Segments.Count: 0 } && File.Exists(partialPath)) { _logger.LogInformation("Existing single-segment partial detected — staying single-segment for resume"); useMultiSegment = false; } // 3. Vérif espace disque (uniquement à l'initial, pas au resume) if (existing is null && job.ExpectedSize > 0) { var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!); var needed = (long)(job.ExpectedSize * 1.05); if (drive.AvailableFreeSpace < needed) throw new IOException( $"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}"); } if (useMultiSegment) { return await DownloadMultiSegmentAsync( job, partialPath, finalPath, existing, requestedSegments, progress, hashProgress, ct).ConfigureAwait(false); } return await DownloadSingleSegmentAsync( job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false); } // =================================================================== // ============== MULTI-SEGMENT (parallèle, fast path) ============== // =================================================================== private async Task DownloadMultiSegmentAsync( DownloadJob job, string partialPath, string finalPath, DownloadState? existing, int segmentCount, IProgress? progress, IProgress? hashProgress, CancellationToken ct) { var swPhase = Stopwatch.StartNew(); // 1. On fait CONFIANCE au manifest : pas de HEAD probe. // Le HEAD ajoutait jusqu'à 30 s-5 min de latence quand le serveur faisait // un md5_file() sur 14 Go à chaque requête (vu sur OVH/gate.php). Le manifest // est signé Ed25519 donc sa taille est de toute façon fiable. Si le serveur // ne supporte pas Range, on le détecte sur le premier segment (200 au lieu // de 206) et on fallback sur single-segment. long total = job.ExpectedSize; if (total <= 0) { _logger.LogInformation("ExpectedSize=0 — falling back to single-segment"); return await DownloadSingleSegmentAsync(job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false); } // 2. State (resume vs fresh) DownloadState state; if (existing is not null && existing.Segments.Count > 0 && existing.TotalBytes == total && File.Exists(partialPath)) { state = existing; _logger.LogInformation("Resuming multi-segment DL for v{Version} ({Segments} segments, {Done}/{Total} bytes)", job.Version, state.Segments.Count, state.DownloadedBytes, state.TotalBytes); } else { // Discard tout résidu (state mismatch ou partial sans state) _stateStore.Discard(job.Version); state = new DownloadState { Version = job.Version, Url = job.Url.ToString(), TotalBytes = total, ExpectedSha256 = job.ExpectedSha256, PartialPath = partialPath, StartedAtUtc = DateTime.UtcNow, LastChunkAtUtc = DateTime.UtcNow, Segments = BuildSegments(total, segmentCount), }; _stateStore.Save(state); } // 3. Pré-allocation du fichier final. // Stratégie : on tente de marquer le fichier en SPARSE (FSCTL_SET_SPARSE) // avant le SetLength, ce qui rend l'opération métadata-only (~milliseconde // au lieu de 5-30 s sur HDD à cause du zero-fill NTFS). // Pas besoin d'admin (contrairement à SetFileValidData). Aucune fuite de // données : les zones non écrites sont lues comme zéros virtuels par NTFS. // Si FSCTL_SET_SPARSE échoue (volume FAT32 ou autre), fallback sur SetLength // classique. var swAlloc = Stopwatch.StartNew(); await Task.Run(() => { using var alloc = new FileStream(partialPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite); if (alloc.Length != total) { bool sparseOk = SparseFileSupport.TrySetSparse(alloc.SafeFileHandle); _logger.LogInformation("Pre-allocating {Path} to {Bytes} bytes (sparse={Sparse})", partialPath, total, sparseOk); alloc.SetLength(total); } }, ct).ConfigureAwait(false); swAlloc.Stop(); _logger.LogInformation("Pre-alloc done in {Ms} ms (HEAD skipped, total phase {TotalMs} ms)", swAlloc.ElapsedMilliseconds, swPhase.ElapsedMilliseconds); // Une fois la pré-alloc terminée, on émet immédiatement un progress 0/total // pour que le footer passe de « Préparation… » à « ⬇ v1.4.6 : 0 / 14 Go ». // La vraie progression suit ~250 ms après quand le reporter task tick. progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, null)); // 4. Lance les workers en parallèle. // Compteur agrégé via Interlocked → pas de lock dans le hot path. // Initialisé sur la somme des seg.DownloadedBytes DURABLES plutôt que sur // state.DownloadedBytes (live aggregate persisté, qui peut être en avance // de la réalité disque si pause survenue entre checkpoints). Sans ça, le // footer afficherait "paused at X" mais segments resumeraient à un point // en arrière, et l'aggregate finirait avec un drift cosmétique. long aggregateBytes = state.Segments.Sum(s => s.DownloadedBytes); // Action passée aux segments : juste un Add atomique (zéro contention). void OnSegmentBytes(int _, long deltaBytes) => Interlocked.Add(ref aggregateBytes, deltaBytes); // Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte. // Tous les 5 s ou 50 Mo, persist le state.json. // Tourne sur un thread séparé pour ne jamais être starvé par les workers DL. // // Calcul du débit : on utilise une FENÊTRE GLISSANTE de ~3 s (12 samples // de 250ms) plutôt qu'un delta instantané sur le dernier tick. Pourquoi : // avec un delta tick-à-tick, dès qu'un segment finit OU que Polly retry // entre 2 tentatives OU qu'il y a un micro-stall réseau, le compteur ne // bouge pas pendant 1-2 ticks → bps = 0 → ETA = null → UI cache les // deux 250-500 ms → clignotement permanent à l'écran (symptôme rapporté). // Avec la fenêtre, un trou de 1-2 ticks est dilué dans 12 samples → le // débit affiché reste stable. La latence d'adaptation est ~3s, ce qui // est largement OK visuellement (l'opérateur ne perçoit pas 3s de retard // sur un chiffre qui de toute façon fluctue de ±5-10 % au cours d'un DL). const int BpsWindowSize = 12; // 12 × 250 ms = 3 s using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var reporterTask = Task.Run(async () => { var sw = Stopwatch.StartNew(); var lastFlush = TimeSpan.Zero; long lastFlushBytes = Interlocked.Read(ref aggregateBytes); // Fenêtre glissante (time, bytes) pour lissage du débit. var bpsWindow = new Queue<(TimeSpan At, long Bytes)>(BpsWindowSize + 1); try { while (!reporterCts.IsCancellationRequested) { await Task.Delay(250, reporterCts.Token).ConfigureAwait(false); var snapshot = Interlocked.Read(ref aggregateBytes); var elapsed = sw.Elapsed; // Push le nouveau sample, évince le plus ancien si on dépasse. bpsWindow.Enqueue((elapsed, snapshot)); while (bpsWindow.Count > BpsWindowSize) bpsWindow.Dequeue(); // bps = (bytes_now - bytes_oldest_in_window) / time_span_window // Fallback à 0 tant qu'on n'a pas au moins 2 samples (le 1er tick). double bps = 0; if (bpsWindow.Count >= 2) { var oldest = bpsWindow.Peek(); var span = (elapsed - oldest.At).TotalSeconds; if (span > 0) bps = (snapshot - oldest.Bytes) / span; } TimeSpan? eta = null; if (bps > 0 && total > snapshot) eta = TimeSpan.FromSeconds((total - snapshot) / bps); progress?.Report(new DownloadProgress(snapshot, total, bps, eta)); if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval || snapshot - lastFlushBytes >= StateFlushBytesInterval) { state.DownloadedBytes = snapshot; state.LastChunkAtUtc = DateTime.UtcNow; _stateStore.Save(state); lastFlush = elapsed; lastFlushBytes = snapshot; } } } catch (OperationCanceledException) { /* attendu en fin de DL */ } }, reporterCts.Token); var tasks = state.Segments .Where(s => !s.Completed) .Select(s => Task.Run(() => _retryPipeline.ExecuteAsync(async innerCt => { await DownloadSegmentAsync(job, state, s, partialPath, OnSegmentBytes, innerCt).ConfigureAwait(false); }, ct).AsTask(), ct)) .ToArray(); try { await Task.WhenAll(tasks).ConfigureAwait(false); } catch { reporterCts.Cancel(); try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ } state.DownloadedBytes = Interlocked.Read(ref aggregateBytes); _stateStore.Save(state); throw; } // Stoppe le reporter et fais un dernier flush reporterCts.Cancel(); try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ } state.DownloadedBytes = Interlocked.Read(ref aggregateBytes); _stateStore.Save(state); progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero)); // 5. Vérif complétion des segments // Le check `actualSize != total` ci-dessous est INSUFFISANT car le fichier est // pré-alloué en sparse à `total` octets — sa taille filesystem matchera toujours // peu importe ce qui a été écrit. Si un segment a échoué silencieusement (cas // pré-0.24.5 ou bug futur), on aurait des trous (zéros NTFS) → SHA KO en sortie // sans diagnostic clair. On vérifie explicitement que CHAQUE segment a atteint // sa cible AVANT de lancer le SHA-256 (qui prend 30s-3min sur un 14 Go). var incomplete = state.Segments.FirstOrDefault(s => !s.Completed || s.DownloadedBytes < s.Length); if (incomplete is not null) { _stateStore.Save(state); // garde l'état pour permettre un resume manuel throw new InvalidDataException( $"Segment {incomplete.Index} incomplet : {incomplete.DownloadedBytes:N0}/{incomplete.Length:N0} octets " + $"(Completed={incomplete.Completed}). Relance le téléchargement pour reprendre."); } var actualSize = new FileInfo(partialPath).Length; if (actualSize != total) { _stateStore.Discard(job.Version); throw new InvalidDataException($"Taille téléchargée incorrecte : attendu {total}, obtenu {actualSize}"); } // 6. Vérif SHA-256 await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false); return finalPath; } private static List BuildSegments(long total, int count) { var segments = new List(count); long segSize = total / count; for (int i = 0; i < count; i++) { long start = i * segSize; long end = (i == count - 1) ? total - 1 : (start + segSize - 1); segments.Add(new DownloadSegment { Index = i, Start = start, End = end, DownloadedBytes = 0, Completed = false, }); } return segments; } private async Task DownloadSegmentAsync( DownloadJob job, DownloadState state, DownloadSegment seg, string partialPath, Action onBytes, CancellationToken ct) { if (seg.Completed) return; var segStart = seg.Start + seg.DownloadedBytes; if (segStart > seg.End) { seg.Completed = true; return; } // Récupère l'URL active (peut avoir été refreshée par un autre segment) var url = await GetOrRefreshUrlAsync(job, forceRefresh: false, ct).ConfigureAwait(false); using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Range = new RangeHeaderValue(segStart, seg.End); if (!string.IsNullOrEmpty(state.Etag)) req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag)); using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); if (resp.StatusCode == HttpStatusCode.OK) { // Le serveur a renvoyé tout au lieu du range : la ressource a probablement changé // (ou il n'a en fait pas accepté le range). On signale un retry à blanc. throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true); } // 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: // expired"), OU le fichier a été renommé/supprimé côté serveur (cas // typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache // OVH CDN). Dans les deux cas, refresh l'URL via le callback du job — // l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant // vers le fichier actuellement présent sur le serveur. Si refresh donne // une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry // avec la nouvelle. Sinon → c'est un vrai 404, on bubble. // // Note importante : on compare à `url` (la valeur utilisée dans la // requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un // snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments // tombent en 404 simultanément, le PREMIER refresh met _currentUrl à // jour, les autres voient le debounce 5s (no-op). Comparer à `url` // (= URL réellement utilisée par CETTE requête, OLD) détecte // correctement que _currentUrl est maintenant NEW → retry. Si on // comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait // un faux 404 fatal sur tous les segments sauf le premier. bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone || resp.StatusCode == HttpStatusCode.NotFound || resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable; if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null) { await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false); bool urlChanged = !Uri.Equals(url, _currentUrl); if (urlChanged) { _logger.LogInformation( "Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying", (int)resp.StatusCode, seg.Index, url, _currentUrl); throw new HttpResumableException( $"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed", isTransient: true); } // URL unchanged → soit le serveur insiste sur la même URL morte, // soit le refresh a fallback sur le manifest cached (donc stale). // 404 non-transient pour arrêter Polly et remonter une erreur claire // qui guide l'opérateur vers « Vérifier les MAJ ». if (resp.StatusCode == HttpStatusCode.NotFound) { throw new HttpResumableException( $"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.", isTransient: false); } // 416 unchanged URL = vrai mismatch de taille. On fait un HEAD probe // pour obtenir la taille RÉELLE côté serveur et la rapporter dans le // message d'erreur — l'opérateur saura ainsi s'il a un upload SFTP // tronqué (file plus petit que prévu) ou un manifest qui ment sur // sizeBytes. Cas concret : SFTP coupe au milieu d'un upload de 13 Go // → le file fait 7 Go sur disque, mais le manifest dit 13 Go. // Segment N qui demande bytes 9G-10G reçoit 416 (out of range). if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) { long? actualSize = null; try { using var headReq = new HttpRequestMessage(HttpMethod.Head, url); using var headResp = await _http.SendAsync(headReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); if (headResp.IsSuccessStatusCode) actualSize = headResp.Content.Headers.ContentLength; } catch (Exception ex) { _logger.LogDebug(ex, "HEAD probe after 416 failed"); } var actualStr = actualSize.HasValue ? $"{actualSize.Value:N0}" : "?"; var expectedStr = $"{state.TotalBytes:N0}"; _logger.LogError( "HTTP 416 on segment {Seg} — actual server file size: {Actual} bytes, manifest expects: {Expected} bytes", seg.Index, actualStr, expectedStr); throw new HttpResumableException( $"HTTP 416 on segment {seg.Index} — incohérence taille de fichier serveur : " + $"le ZIP sur le serveur fait {actualStr} octets, le manifest attend {expectedStr} octets. " + $"Probable cause : upload SFTP tronqué OU manifest avec sizeBytes/sha256 obsolètes. " + $"Re-upload le ZIP complet puis « 🔁 Hasher les versions + signer » côté admin.", isTransient: false); } throw new HttpResumableException( $"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true); } if (!resp.IsSuccessStatusCode) { var transient = (int)resp.StatusCode is 408 or 429 or >= 500; throw new HttpResumableException($"HTTP {(int)resp.StatusCode} on segment {seg.Index}", isTransient: transient); } if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag; if (resp.Content.Headers.LastModified is { } lm) state.LastModified = lm.ToString("R"); await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true); dst.Seek(segStart, SeekOrigin.Begin); // CHECKPOINT : durabilité ET anti-double-comptage. // // Durabilité : on n'incrémente seg.DownloadedBytes qu'APRÈS un FlushAsync // réussi. Sans ça, sur pause/cancel, le buffer FileStream (4 MiB) ou le // cache OS peuvent ne pas avoir atteint le disque, mais seg.DownloadedBytes // les compte comme écrits → resume saute ces bytes → trou (zéros NTFS du // pre-alloc sparse) → SHA-256 fail. // // Anti-double-comptage : on appelle ÉGALEMENT `onBytes` uniquement au // checkpoint, avec le delta inFlight. Sans ça, un retry Polly mid-segment // (très fréquent sur PHP-FPM OVH mutualisé qui coupe les requêtes longues) // re-télécharge les bytes depuis le dernier checkpoint et les compte une // 2e fois dans l'aggregate → footer affiche >100% en fin de DL. // Avec onBytes au checkpoint : les bytes re-téléchargés n'ont jamais été // reportés à la 1re tentative (l'erreur Polly est levée AVANT le checkpoint // sur l'attempt failed), donc pas de double count. // // Trade-off : UI/footer update tous les 64 MiB par segment au lieu de // chaque 4 MiB. Avec 16 segments en parallèle, ça reste ~10-20 updates/s // au pic, le reporter task échantillonne à 4 Hz donc invisible côté UX. const long FlushIntervalBytes = 64L * 1024 * 1024; var buffer = new byte[BufferSize]; long inFlight = 0; int n; while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0) { await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false); inFlight += n; if (inFlight >= FlushIntervalBytes) { await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false); seg.DownloadedBytes += inFlight; onBytes(seg.Index, inFlight); inFlight = 0; } } // Checkpoint final : flush + ack du reste du buffer. await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false); if (inFlight > 0) { seg.DownloadedBytes += inFlight; onBytes(seg.Index, inFlight); inFlight = 0; } seg.Completed = (seg.DownloadedBytes >= seg.Length); // Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a // fermé la connexion sans erreur HTTP (typique : PHP-FPM hit `request_terminate_timeout` // sur un gros segment, OVH coupe la connexion silencieusement, proxy intermédiaire qui // ferme). Sans cette détection, le segment retourne `Completed=false` mais sans exception, // Polly ne retry pas, le fichier final a des trous (zones sparse jamais écrites = zéros // NTFS) et le SHA-256 échoue à la fin. On lance une exception transitoire pour forcer // un retry — celui-ci reprendra à l'offset où on s'est arrêté grâce à seg.DownloadedBytes. if (!seg.Completed) { var missing = seg.Length - seg.DownloadedBytes; throw new HttpResumableException( $"Segment {seg.Index} : connexion fermée prématurément, {missing:N0} octets manquants (probablement timeout PHP-FPM serveur)", isTransient: true); } } // =================================================================== // ============= SINGLE-SEGMENT (legacy / fallback path) ============ // =================================================================== private async Task DownloadSingleSegmentAsync( DownloadJob job, string partialPath, string finalPath, DownloadState? existing, IProgress? progress, IProgress? hashProgress, CancellationToken ct) { long resumeFrom = 0; if (existing is not null && File.Exists(partialPath)) { resumeFrom = new FileInfo(partialPath).Length; if (resumeFrom != existing.DownloadedBytes) { _logger.LogInformation( "State/file size mismatch ({StateBytes} vs {FileBytes}) — using file size", existing.DownloadedBytes, resumeFrom); existing.DownloadedBytes = resumeFrom; } } else if (File.Exists(partialPath)) { File.Delete(partialPath); resumeFrom = 0; } var state = existing ?? new DownloadState { Version = job.Version, Url = job.Url.ToString(), TotalBytes = job.ExpectedSize, DownloadedBytes = 0, ExpectedSha256 = job.ExpectedSha256, PartialPath = partialPath, StartedAtUtc = DateTime.UtcNow, LastChunkAtUtc = DateTime.UtcNow, }; await _retryPipeline.ExecuteAsync(async innerCt => { await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false); }, ct).ConfigureAwait(false); var actualSize = new FileInfo(partialPath).Length; if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize) { _stateStore.Discard(job.Version); throw new InvalidDataException( $"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}"); } await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false); return finalPath; } private async Task DownloadChunkAsync( DownloadJob job, DownloadState state, string partialPath, IProgress? progress, CancellationToken ct) { var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0; if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return; using var req = new HttpRequestMessage(HttpMethod.Get, job.Url); if (resumeFrom > 0) { req.Headers.Range = new RangeHeaderValue(resumeFrom, null); if (!string.IsNullOrEmpty(state.Etag)) req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag)); else if (!string.IsNullOrEmpty(state.LastModified) && DateTimeOffset.TryParse(state.LastModified, out var lm)) req.Headers.IfRange = new RangeConditionHeaderValue(lm); } _logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom); using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK) { _logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh"); resp.Dispose(); File.Delete(partialPath); _stateStore.Discard(job.Version); throw new HttpResumableException("Resource changed on server, restart", isTransient: true); } if (!resp.IsSuccessStatusCode) { var transient = (int)resp.StatusCode is 408 or 429 or >= 500; throw new HttpResumableException( $"HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}", isTransient: transient); } if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag; if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R"); var contentLength = resp.Content.Headers.ContentLength ?? 0; var total = state.TotalBytes; if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl }) total = cl; else if (resp.StatusCode == HttpStatusCode.OK && contentLength > 0) total = contentLength; if (total > 0) state.TotalBytes = total; var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create; await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true); var buffer = new byte[BufferSize]; long read = resumeFrom; var sw = Stopwatch.StartNew(); var lastReport = sw.Elapsed; long lastReportBytes = read; var lastFlush = sw.Elapsed; long lastFlushBytes = read; int n; while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0) { await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false); read += n; state.DownloadedBytes = read; state.LastChunkAtUtc = DateTime.UtcNow; var elapsed = sw.Elapsed; if ((elapsed - lastReport).TotalMilliseconds >= 250) { var deltaBytes = read - lastReportBytes; var deltaSeconds = (elapsed - lastReport).TotalSeconds; var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0; TimeSpan? eta = null; if (bps > 0 && state.TotalBytes > 0) eta = TimeSpan.FromSeconds((state.TotalBytes - read) / bps); progress?.Report(new DownloadProgress(read, state.TotalBytes, bps, eta)); lastReport = elapsed; lastReportBytes = read; } if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval || read - lastFlushBytes >= StateFlushBytesInterval) { await dst.FlushAsync(ct).ConfigureAwait(false); _stateStore.Save(state); lastFlush = elapsed; lastFlushBytes = read; } } await dst.FlushAsync(ct).ConfigureAwait(false); _stateStore.Save(state); progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero)); } // =================================================================== // ====================== FINALISATION COMMUNE ====================== // =================================================================== private async Task VerifyAndFinalizeAsync( DownloadJob job, string partialPath, string finalPath, IProgress? hashProgress, CancellationToken ct) { // Évalue si on doit vérifier le hash : // 1. Le manifest doit fournir un sha256 valide (pas vide, pas "REPLACE…") // 2. L'algo demandé n'est pas "none" // 3. La config locale n'a pas désactivé la vérification globale bool hashAlgoSkipped = string.Equals(job.HashAlgorithm, "none", StringComparison.OrdinalIgnoreCase); bool hasUsableSha = !string.IsNullOrEmpty(job.ExpectedSha256) && !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase); if (hasUsableSha && !hashAlgoSkipped && _config.VerifyDownloadHash) { _logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath); var sw = Stopwatch.StartNew(); var sha = await _integrity.ComputeSha256Async(partialPath, hashProgress, ct).ConfigureAwait(false); sw.Stop(); _logger.LogInformation("SHA-256 verified in {Elapsed} ({MBps:0.#} MB/s)", sw.Elapsed, sw.Elapsed.TotalSeconds > 0 ? new FileInfo(partialPath).Length / sw.Elapsed.TotalSeconds / (1024.0 * 1024.0) : 0); if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase)) { _stateStore.Discard(job.Version); throw new InvalidDataException( $"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}"); } } else if (hashAlgoSkipped) { _logger.LogInformation("Hash verification skipped (manifest specifies hashAlgorithm=none)"); } else if (!_config.VerifyDownloadHash) { _logger.LogInformation("Hash verification skipped (VerifyDownloadHash=false in local config)"); } else { _logger.LogWarning("SHA-256 absent du manifest, vérification ignorée"); } if (File.Exists(finalPath)) File.Delete(finalPath); File.Move(partialPath, finalPath); _stateStore.Discard(job.Version); } } /// Exception interne pour signaler à Polly qu'on doit retry. internal sealed class HttpResumableException : Exception { public bool IsTransient { get; } public HttpResumableException(string message, bool isTransient) : base(message) { IsTransient = isTransient; } } internal static class SparseFileSupport { private const uint FSCTL_SET_SPARSE = 0x000900C4; [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool DeviceIoControl( SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); /// /// Marque le fichier comme sparse via FSCTL_SET_SPARSE. /// Une fois sparse, SetLength(14 GB) est métadata-only (< 100 ms partout). /// Les zones non écrites sont lues comme zéros virtuels (pas de fuite de données /// disque, contrairement à SetFileValidData qui exigerait SeManageVolumePrivilege). /// Retourne false si le volume ne supporte pas sparse (ex. FAT32) — auquel cas /// on retombe sur le SetLength classique avec zero-fill. /// public static bool TrySetSparse(SafeFileHandle handle) { if (!OperatingSystem.IsWindows()) return false; try { return DeviceIoControl(handle, FSCTL_SET_SPARSE, IntPtr.Zero, 0, IntPtr.Zero, 0, out _, IntPtr.Zero); } catch { return false; } } }