Bug : sur un manifest avec deux entrées partageant le même numéro de version (channels multiples — ex proserve-firefighter-1.5.4.32.zip ET proserve-full- 1.5.4.32.zip, deux builds distincts pour firefighter vs full), cliquer le bouton « 🔁 Hash » d'une ligne déclenchait le hash des DEUX ZIPs dans la même requête HTTP. Résultat 2 × 14 Go = 28 Go dans une seule requête, ce qui dépasse le timeout front d'OVH mutualisé (hard-limit invisible côté PHP même avec set_time_limit(0)) → 500 Internal Server Error. C'est pour ça que « des hashs de la même taille passaient sans problème avant » : c'est le cas 28 Go des DEUX ZIPs en une requête qui est nouveau (depuis l'introduction des channels), pas la taille du single 14 Go qui passait déjà. Fix : nouveau paramètre $onlyEntryId sur SignManifest::run(). Prend le pas sur $onlyVersion. L'admin action sync_one passe l'entryId (unique par ligne, généré par generate_entry_id()) au lieu du numéro de version. Une ligne cliquée = un ZIP hashé, point. Le bouton « 🔁 Sync (all versions) » (action sync_versions) continue de tout hasher — c'est ce que l'opérateur demande explicitement. Rétro-compat : si $onlyEntryId est null (CLI, cron, anciens callers), $onlyVersion filtre comme avant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
421 lines
22 KiB
PHP
421 lines
22 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace PSLauncher\Tools;
|
||
|
||
/**
|
||
* Logique de re-signature du manifest, sans dépendance shell.
|
||
* Utilisable depuis le CLI (tools/sign-manifest.php) et depuis le backoffice
|
||
* web (admin/versions.php → action 'sync'). Évite tout appel à exec().
|
||
*/
|
||
final class SignManifest
|
||
{
|
||
public string $manifestPath;
|
||
public string $buildsDir;
|
||
public ?string $configPath;
|
||
public string $hashCachePath;
|
||
/** @var string[] */
|
||
public array $log = [];
|
||
|
||
public function __construct(string $rootDir)
|
||
{
|
||
$this->manifestPath = $rootDir . '/manifest/versions.json';
|
||
$this->buildsDir = $rootDir . '/builds';
|
||
$this->configPath = $rootDir . '/api/config.php';
|
||
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
|
||
// { size, mtime, sha256 } ; on recalcule uniquement si size ou mtime ont changé.
|
||
// Vit hors du répertoire web servi (sécurité : un utilisateur n'a aucune raison
|
||
// de pouvoir lire le cache).
|
||
$this->hashCachePath = $rootDir . '/manifest/.hashcache.json';
|
||
}
|
||
|
||
private function out(string $line): void { $this->log[] = $line; }
|
||
|
||
/**
|
||
* Lit le cache des hashs précalculés. Retourne un dictionnaire
|
||
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
|
||
* @return array<string, array{size:int,mtime:int,sha256:string}>
|
||
*/
|
||
private function loadHashCache(): array
|
||
{
|
||
if (!is_file($this->hashCachePath)) return [];
|
||
$raw = @file_get_contents($this->hashCachePath);
|
||
if ($raw === false) return [];
|
||
$j = json_decode($raw, true);
|
||
return is_array($j) ? $j : [];
|
||
}
|
||
|
||
/** @param array<string, array{size:int,mtime:int,sha256:string}> $cache */
|
||
private function saveHashCache(array $cache): void
|
||
{
|
||
@file_put_contents($this->hashCachePath, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||
}
|
||
|
||
/**
|
||
* Calcule (ou retourne depuis le cache) le SHA-256 d'un fichier.
|
||
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
|
||
* ce qui permet de garder le cache à jour sans intervention manuelle.
|
||
*
|
||
* IMPORTANT : si <code>$force</code> est true, on IGNORE le cache et on
|
||
* recalcule (puis on rafraîchit le cache). Sans ça, le cache interne
|
||
* supplantait silencieusement le `--force` du caller — bug subtil parce
|
||
* que la couche outer de `run()` avait déjà skip le cache (sha REPLACE
|
||
* ou force=true), mais ce helper retombait sur sa propre cache lookup
|
||
* (clé = realpath) → retournait instantanément l'ancien hash sans
|
||
* recalculer, même après que l'opérateur ait re-uploadé le ZIP via
|
||
* SFTP avec mtime préservé.
|
||
*
|
||
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
|
||
* @return array{sha256:string, fromCache:bool, durationMs:int}
|
||
*/
|
||
private function getOrComputeSha256(string $path, array &$cache, bool $force = false): array
|
||
{
|
||
$size = filesize($path) ?: 0;
|
||
$mtime = filemtime($path) ?: 0;
|
||
$key = realpath($path) ?: $path;
|
||
|
||
if (!$force
|
||
&& isset($cache[$key])
|
||
&& ($cache[$key]['size'] ?? -1) === $size
|
||
&& ($cache[$key]['mtime'] ?? -1) === $mtime
|
||
&& !empty($cache[$key]['sha256'])) {
|
||
return ['sha256' => $cache[$key]['sha256'], 'fromCache' => true, 'durationMs' => 0];
|
||
}
|
||
|
||
$start = microtime(true);
|
||
// Streamed hash en chunks 16 Mo. Deux bénéfices vs hash_file() atomique :
|
||
// 1. On peut envoyer un heartbeat (flush()) entre chunks pour que le
|
||
// proxy Apache/OVH ne timeout pas la requête pendant les ~5-10 min
|
||
// que prend un SHA-256 sur un ZIP de 14 Go via SAN mutualisé.
|
||
// 2. On peut relever set_time_limit() à chaque chunk (fenêtre glissante)
|
||
// au lieu de faire un unique set_time_limit(0) à la caller.
|
||
// Mémoire : hash_update_stream() ne buffere pas — c'est du streaming pur.
|
||
$fp = @fopen($path, 'rb');
|
||
if ($fp === false) {
|
||
return ['sha256' => '', 'fromCache' => false, 'durationMs' => 0];
|
||
}
|
||
try {
|
||
$ctx = hash_init('sha256');
|
||
// 16 Mo = compromis entre nombre d'appels PHP et pression CPU par read()
|
||
$chunkBytes = 16 * 1024 * 1024;
|
||
while (!feof($fp)) {
|
||
hash_update_stream($ctx, $fp, $chunkBytes);
|
||
// Fenêtre glissante : autorise ~5 min de plus avant que PHP ne
|
||
// timeout. Sur un fichier de 14 Go / chunks 16 Mo = ~900 itérations,
|
||
// donc si un chunk prend >5 min c'est vraiment que le disque est HS.
|
||
@set_time_limit(300);
|
||
// Heartbeat côté front — évite Apache RequestTimeout / OVH proxy
|
||
// timeout sur les grosses requêtes. Silencieux si output buffering
|
||
// est actif (pas fatal).
|
||
@ob_flush();
|
||
@flush();
|
||
}
|
||
$sha = hash_final($ctx);
|
||
} finally {
|
||
fclose($fp);
|
||
}
|
||
$duration = (int)((microtime(true) - $start) * 1000);
|
||
|
||
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
|
||
return ['sha256' => $sha, 'fromCache' => false, 'durationMs' => $duration];
|
||
}
|
||
|
||
/**
|
||
* Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
|
||
*
|
||
* Stratégie de hash :
|
||
* - Cache disque {$buildsDir}/.hashcache.json indexé par chemin → (size, mtime, sha256)
|
||
* - On re-hash seulement si la taille ou mtime du ZIP a changé (= upload de nouveau build)
|
||
* - Si le manifest contient déjà un sha256 valide ET que size+mtime n'ont pas changé,
|
||
* on évite carrément l'appel à hash_file()
|
||
* - Si une version a `download.hashAlgorithm = "none"` ou `download.skipHash = true`
|
||
* dans le manifest, on ne calcule pas le hash (sha256 reste à null/empty)
|
||
* - $force = true pour forcer un recalcul intégral (utile en cas de doute)
|
||
*
|
||
* Sur un mutualisé OVH avec 5 versions × 14 Go : avant ~25 min, après ~quelques secondes
|
||
* pour les versions inchangées + ~1 min par nouvelle version uploadée.
|
||
*
|
||
* @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
|
||
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
|
||
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
|
||
* versions[] (les autres restent inchangées). Le scope 'launcher'
|
||
* est ignoré dans ce cas. ATTENTION : si plusieurs entrées
|
||
* partagent le même numéro de version (channels firefighter
|
||
* vs full sur 1.5.4.32 p.ex.), TOUTES sont hashées — d'où
|
||
* $onlyEntryId ci-dessous pour cibler une seule ligne.
|
||
* @param ?string $onlyEntryId Si non null, ne touche QUE l'entrée avec cet id (généré par
|
||
* generate_entry_id() au backoffice). Prend le pas sur
|
||
* $onlyVersion. Utilisé par le bouton « 🔁 Hash » d'une
|
||
* ligne isolée pour éviter de re-hasher les autres channels
|
||
* qui partagent le même numéro de version (2 × 14 Go dans une
|
||
* requête HTTP → risque de timeout front OVH).
|
||
* @return array{ok:bool, log:string[]}
|
||
*/
|
||
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null, ?string $onlyEntryId = null): array
|
||
{
|
||
// Sur OVH mutualisé, un SHA-256 d'un ZIP de 14 Go peut prendre 5-10 min via
|
||
// le SAN partagé. Le max_execution_time par défaut (30-60s) tue le process
|
||
// → Apache retourne 500 Internal Server Error avec le boilerplate
|
||
// postmaster@… — c'est le mode d'échec principal du bouton « Hash » au
|
||
// backoffice. On désactive la limite ici (couvre AUSSI les callers CLI et
|
||
// cron, pas seulement l'admin web). ignore_user_abort évite qu'un refresh
|
||
// ou une fermeture d'onglet côté opérateur interrompe un hash en cours.
|
||
@set_time_limit(0);
|
||
@ignore_user_abort(true);
|
||
|
||
// Capture toutes les erreurs PHP (warnings + fatals + exceptions non-catchées)
|
||
// dans un fichier accessible via SFTP, à côté du manifest. Sur OVH mutualisé
|
||
// les logs Apache ne sont accessibles que via le manager web — pas pratique
|
||
// pour un diagnostic rapide de 500. Ce fichier permet à l'opérateur de le
|
||
// grep post-clic sans passer par le manager.
|
||
$errorLogPath = dirname($this->manifestPath) . '/.signmanifest-error.log';
|
||
@ini_set('log_errors', '1');
|
||
@ini_set('error_log', $errorLogPath);
|
||
@error_reporting(E_ALL);
|
||
$ts = date('Y-m-d H:i:s');
|
||
@file_put_contents($errorLogPath,
|
||
"[{$ts}] --- SignManifest::run(scope={$scope}, force=" . ($force?'1':'0')
|
||
. ", onlyVersion=" . ($onlyVersion ?? 'null')
|
||
. ", onlyEntryId=" . ($onlyEntryId ?? 'null') . ") ---\n",
|
||
FILE_APPEND);
|
||
// Fatal errors → capturés par un shutdown handler. Sinon Apache renvoie
|
||
// juste 500 sans qu'on sache ce qui a claqué.
|
||
register_shutdown_function(function () use ($errorLogPath) {
|
||
$err = error_get_last();
|
||
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
|
||
@file_put_contents($errorLogPath,
|
||
"[" . date('Y-m-d H:i:s') . "] FATAL " . $err['type']
|
||
. " : {$err['message']} @ {$err['file']}:{$err['line']}\n",
|
||
FILE_APPEND);
|
||
}
|
||
});
|
||
|
||
if (!is_file($this->manifestPath)) {
|
||
$this->out("Manifest introuvable : {$this->manifestPath}");
|
||
return ['ok' => false, 'log' => $this->log];
|
||
}
|
||
|
||
$manifest = json_decode(file_get_contents($this->manifestPath), true);
|
||
if (!is_array($manifest)) {
|
||
$this->out("Manifest illisible (JSON invalide)");
|
||
return ['ok' => false, 'log' => $this->log];
|
||
}
|
||
|
||
$doVersions = ($scope === 'all' || $scope === 'versions');
|
||
$doLauncher = ($scope === 'all' || $scope === 'launcher');
|
||
if ($onlyVersion !== null || $onlyEntryId !== null) {
|
||
// Mode "hash une seule entrée / version" : on ne touche pas au launcher,
|
||
// et on ne hash que ce qui correspond au filtre demandé dans versions[].
|
||
$doLauncher = false;
|
||
$doVersions = true;
|
||
}
|
||
|
||
// Charge le cache des hashs (indexé par chemin réel du ZIP)
|
||
$cache = $this->loadHashCache();
|
||
$cacheChanged = false;
|
||
|
||
$hashedVersions = [];
|
||
if ($doVersions) foreach ($manifest['versions'] as &$v) {
|
||
$version = $v['version'] ?? '?';
|
||
$entryId = (string)($v['id'] ?? '');
|
||
// Filtre par id d'entrée prioritaire sur filtre par version (cas
|
||
// channels multiples partageant un numéro de version identique —
|
||
// ex : proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32).
|
||
// Sinon, filtre par numéro de version (rétro-compat).
|
||
if ($onlyEntryId !== null && $entryId !== $onlyEntryId) {
|
||
continue;
|
||
}
|
||
if ($onlyEntryId === null && $onlyVersion !== null && $version !== $onlyVersion) {
|
||
continue; // skip silently les autres versions
|
||
}
|
||
$url = $v['download']['url'] ?? '';
|
||
if ($url === '') {
|
||
$this->out(" [skip] $version : pas d'URL dans le manifest");
|
||
continue;
|
||
}
|
||
|
||
$filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
|
||
$zip = "{$this->buildsDir}/$filename";
|
||
|
||
if (!is_file($zip)) {
|
||
$candidates = glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: [];
|
||
$candidates = array_values(array_filter($candidates, 'is_file'));
|
||
if (count($candidates) === 1) {
|
||
$zip = $candidates[0];
|
||
$this->out(" [info] $version : URL pointait vers '{$filename}', utilisé '" . basename($zip) . "' à la place");
|
||
} else {
|
||
$this->out(" [skip] $version : ZIP introuvable pour $url");
|
||
if (count($candidates) > 1) {
|
||
$this->out(" Plusieurs candidats : " . implode(', ', array_map('basename', $candidates)));
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$size = filesize($zip) ?: 0;
|
||
$v['download']['sizeBytes'] = $size;
|
||
|
||
// Hash skipping : per-version flag dans le manifest. Si "none" ou skipHash=true,
|
||
// on neutralise le sha256 et la vérif côté client est passée. Utile pour les builds
|
||
// internes ou les très gros ZIPs où on accepte le compromis perf/sécurité (la
|
||
// signature Ed25519 du manifest reste). À utiliser avec parcimonie.
|
||
$algo = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256'));
|
||
$skipFlag = !empty($v['download']['skipHash']);
|
||
if ($algo === 'none' || $skipFlag) {
|
||
$v['download']['sha256'] = '';
|
||
$v['download']['hashAlgorithm'] = 'none';
|
||
$this->out(" [skip-hash] $version : " . basename($zip) . " ($size octets) — hash non calculé (hashAlgorithm=none)");
|
||
$hashedVersions[] = $version;
|
||
continue;
|
||
}
|
||
|
||
// Cache lookup (sauf si --force)
|
||
if (!$force) {
|
||
$key = realpath($zip) ?: $zip;
|
||
$existingSha = (string)($v['download']['sha256'] ?? '');
|
||
$hasValidSha = $existingSha !== ''
|
||
&& !str_starts_with($existingSha, 'REPLACE');
|
||
if ($hasValidSha
|
||
&& isset($cache[$key])
|
||
&& ($cache[$key]['size'] ?? -1) === $size
|
||
&& ($cache[$key]['mtime'] ?? -1) === (filemtime($zip) ?: 0)
|
||
&& ($cache[$key]['sha256'] ?? '') === $existingSha) {
|
||
$this->out(" [cache] $version : " . basename($zip) . " ($size octets) — sha256 inchangé, hash cache hit");
|
||
$hashedVersions[] = $version;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Log préalable au hash — utile pour identifier QUEL ZIP a fait
|
||
// planter le process si on retombe sur le 500. Sans ça, l'error log
|
||
// dit juste "PHP Fatal…" sans savoir si c'est le 1er ou le Nième ZIP.
|
||
@file_put_contents($errorLogPath,
|
||
"[" . date('Y-m-d H:i:s') . "] START hash $version → $zip ($size octets)\n",
|
||
FILE_APPEND);
|
||
try {
|
||
$r = $this->getOrComputeSha256($zip, $cache, $force);
|
||
} catch (\Throwable $e) {
|
||
@file_put_contents($errorLogPath,
|
||
"[" . date('Y-m-d H:i:s') . "] EXCEPTION during hash of $version : "
|
||
. get_class($e) . " : {$e->getMessage()} @ {$e->getFile()}:{$e->getLine()}\n"
|
||
. $e->getTraceAsString() . "\n",
|
||
FILE_APPEND);
|
||
$this->out(" [ERROR] $version : hash failed — " . $e->getMessage());
|
||
continue;
|
||
}
|
||
@file_put_contents($errorLogPath,
|
||
"[" . date('Y-m-d H:i:s') . "] DONE hash $version → sha256={$r['sha256']} ({$r['durationMs']} ms, fromCache=" . ($r['fromCache']?'1':'0') . ")\n",
|
||
FILE_APPEND);
|
||
$cacheChanged = true;
|
||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
|
||
$v['download']['sha256'] = $r['sha256'];
|
||
$hashedVersions[] = $version;
|
||
}
|
||
unset($v);
|
||
|
||
// Section launcher : si présente, on tente de hasher le PSLauncher-{ver}.exe
|
||
// dans builds/launcher/. On ignore proprement si l'exe n'est pas là.
|
||
if ($doLauncher && isset($manifest['launcher']) && is_array($manifest['launcher'])) {
|
||
$launcher = &$manifest['launcher'];
|
||
$lver = $launcher['version'] ?? '';
|
||
$lurl = $launcher['download']['url'] ?? '';
|
||
if ($lver !== '' && $lurl !== '') {
|
||
$lfile = basename(parse_url($lurl, PHP_URL_PATH) ?: '');
|
||
$lpath = "{$this->buildsDir}/launcher/{$lfile}";
|
||
if (!is_file($lpath)) {
|
||
// Fallback : tolérant sur le nom (PSLauncher-X.Y.Z.exe, etc.)
|
||
$candidates = glob("{$this->buildsDir}/launcher/*{$lver}*.exe", GLOB_NOSORT) ?: [];
|
||
if (count($candidates) === 1) $lpath = $candidates[0];
|
||
}
|
||
if (is_file($lpath)) {
|
||
$lsize = filesize($lpath) ?: 0;
|
||
$launcher['download']['sizeBytes'] = $lsize;
|
||
|
||
// Cache lookup pour le launcher exe aussi
|
||
$lkey = realpath($lpath) ?: $lpath;
|
||
$existingLsha = (string)($launcher['download']['sha256'] ?? '');
|
||
$hasValidLsha = $existingLsha !== '' && !str_starts_with($existingLsha, 'REPLACE');
|
||
if (!$force
|
||
&& $hasValidLsha
|
||
&& isset($cache[$lkey])
|
||
&& ($cache[$lkey]['size'] ?? -1) === $lsize
|
||
&& ($cache[$lkey]['mtime'] ?? -1) === (filemtime($lpath) ?: 0)
|
||
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
|
||
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
|
||
} else {
|
||
$r = $this->getOrComputeSha256($lpath, $cache, $force);
|
||
$cacheChanged = true;
|
||
$launcher['download']['sha256'] = $r['sha256'];
|
||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$r['sha256']} $note");
|
||
}
|
||
} else {
|
||
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
|
||
}
|
||
}
|
||
unset($launcher);
|
||
}
|
||
|
||
// Bump auto de `latest` : prend la plus haute version dispo (sha256 valide OU
|
||
// hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
|
||
// qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
|
||
// ancienne version isolée via $onlyVersion.
|
||
if ($doVersions) {
|
||
$eligible = [];
|
||
foreach ($manifest['versions'] as $vEntry) {
|
||
$sha = (string)($vEntry['download']['sha256'] ?? '');
|
||
$algo = strtolower((string)($vEntry['download']['hashAlgorithm'] ?? 'sha256'));
|
||
$hasValid = ($sha !== '' && !str_starts_with($sha, 'REPLACE')) || $algo === 'none';
|
||
if ($hasValid) $eligible[] = $vEntry['version'];
|
||
}
|
||
if (!empty($eligible)) {
|
||
usort($eligible, 'version_compare');
|
||
$newLatest = end($eligible);
|
||
if (($manifest['latest'] ?? null) !== $newLatest) {
|
||
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
|
||
$manifest['latest'] = $newLatest;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Signature Ed25519
|
||
if ($this->configPath && is_file($this->configPath)) {
|
||
$config = require $this->configPath;
|
||
$sk = $config['ed25519']['private_key_hex'] ?? '';
|
||
if ($sk !== '' && strlen($sk) === 128) {
|
||
$manifest['signature'] = null;
|
||
$cryptoPath = dirname($this->configPath) . '/lib/Crypto.php';
|
||
if (is_file($cryptoPath)) require_once $cryptoPath;
|
||
if (class_exists('\PSLauncher\Crypto')) {
|
||
$payload = \PSLauncher\Crypto::canonicalJson($manifest);
|
||
$manifest['signature'] = \PSLauncher\Crypto::signEd25519($payload, $sk);
|
||
$this->out(" [sign] manifest signé (Ed25519)");
|
||
} else {
|
||
$this->out(" [warn] Classe \\PSLauncher\\Crypto introuvable, manifest non signé");
|
||
}
|
||
} else {
|
||
$this->out(" [warn] ed25519.private_key_hex non configuré, manifest non signé");
|
||
}
|
||
}
|
||
|
||
$jsonOut = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||
if (file_put_contents($this->manifestPath, $jsonOut) === false) {
|
||
$this->out("Échec d'écriture du manifest : {$this->manifestPath}");
|
||
return ['ok' => false, 'log' => $this->log];
|
||
}
|
||
|
||
// Persiste le cache de hashs si on l'a touché. On nettoie aussi les entrées
|
||
// obsolètes (fichiers supprimés) pour éviter qu'il grossisse indéfiniment.
|
||
if ($cacheChanged) {
|
||
foreach (array_keys($cache) as $cachedPath) {
|
||
if (!is_file($cachedPath)) unset($cache[$cachedPath]);
|
||
}
|
||
$this->saveHashCache($cache);
|
||
}
|
||
|
||
$this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
|
||
return ['ok' => true, 'log' => $this->log];
|
||
}
|
||
}
|