Admin backoffice — UI redesign : - Pages versions.php + licenses.php : remplace les <details> inline qui débordaient horizontalement par UN bouton « ✎ Modifier » par row qui ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes, BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel, BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets. - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la clé aux clients. - Overlay « hashing en cours » plein écran sur tous les boutons de hash (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé par scope (bulk vs single). - Date de release passée de datetime-local à date (l'heure n'a pas de sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an pour min_license_date (= license standard couvre les releases sur 1 an). Emails de notification release : - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV) - Onglet « Contacts » sur la modal licenses pour saisir les emails par license (parsing tolérant : CSV, ligne par ligne, point-virgule) - Bouton « ✉ Notifier » par version : POST notify_release filtre les licenses éligibles (channel match + min_license_date + can_see_betas pour BÊTA) et envoie un email HTML à chaque contact (dédup global) - Template email table-based + bgcolor (compat Outlook/Word engine), navy foncé #0F172A, logo Asterion en CID embed (= affichage direct sans demande de permission Outlook), bouton download installer centré (align="center" + margin auto), release notes en <pre> - Mailer.php helper : parse emails, multipart/related avec attachments inline, fallback execCommand pour clipboard 4-digit version support (X.Y.Z.B) : - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent = 0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve le format d'origine au round-trip. - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php, Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit - Use case : dev/test iterations cohabitant avec leur release stable Bugs fixes : - migrate.php : strip ligne par ligne les commentaires SQL avant le check is-empty. Sans ça, le PREMIER chunk d'un fichier migration (= header + premier ALTER) commençait par `--` et était silencieusement skip → ALTER jamais appliqué. Affectait migrations 003, 004. - SignManifest::getOrComputeSha256 ignorait son cache interne quand force demandé par le caller, retournant l'ancien hash en 0 ms même après re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force. Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique. - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh triggers, avec HEAD probe pour comparer taille serveur vs manifest → message d'erreur explicite si ZIP tronqué. Bps display lissé sur une fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un segment finit / Polly retry. SHA mismatch popup enrichi avec les deux SHAs (attendu vs calculé) extraits via regex de l'exception. - DownloadUrl.php : signature de l'URL utilisait un template hardcodé /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit maintenant download.url du manifest et signe le filename réel. Strings i18n (5 langues) : - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch, stale manifest, manifest refreshed auto-retry, force fresh menu Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
899 lines
43 KiB
C#
899 lines
43 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="LocalConfig.ParallelDownloadSegments"/>) ;
|
||
/// - 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 <c>SetLength</c>. Speed-up typique observé : ×4 à ×10.
|
||
/// </summary>
|
||
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<DownloadManager> _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<DownloadManager> 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<Exception>(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<string> DownloadAsync(
|
||
DownloadJob job,
|
||
IProgress<DownloadProgress>? progress,
|
||
CancellationToken ct)
|
||
=> DownloadAsync(job, progress, null, ct);
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private async Task<Uri> 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<string> DownloadAsync(
|
||
DownloadJob job,
|
||
IProgress<DownloadProgress>? progress,
|
||
IProgress<double>? 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<string> DownloadMultiSegmentAsync(
|
||
DownloadJob job,
|
||
string partialPath,
|
||
string finalPath,
|
||
DownloadState? existing,
|
||
int segmentCount,
|
||
IProgress<DownloadProgress>? progress,
|
||
IProgress<double>? 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<DownloadSegment> BuildSegments(long total, int count)
|
||
{
|
||
var segments = new List<DownloadSegment>(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<int, long> 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<string> DownloadSingleSegmentAsync(
|
||
DownloadJob job,
|
||
string partialPath,
|
||
string finalPath,
|
||
DownloadState? existing,
|
||
IProgress<DownloadProgress>? progress,
|
||
IProgress<double>? 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<DownloadProgress>? 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<double>? 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);
|
||
}
|
||
}
|
||
|
||
/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
|
||
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);
|
||
|
||
/// <summary>
|
||
/// Marque le fichier comme sparse via FSCTL_SET_SPARSE.
|
||
/// Une fois sparse, <c>SetLength(14 GB)</c> 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.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|