Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 126
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeDetectorService
0.00% covered (danger)
0.00%
0 / 126
0.00% covered (danger)
0.00%
0 / 10
1122
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 compare
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getExcluded
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setExcluded
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 assessRisk
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 scanDirectory
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 extract
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 buildDiff
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
72
 isExcluded
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 deleteDirectory
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Modules\Updater\Services\Core;
4
5use App\Modules\Updater\Contracts\ChangeDetectorInterface;
6use App\Modules\Updater\Contracts\ChangeDiffInterface;
7use App\Modules\Updater\Contracts\ProgressTrackerInterface;
8use App\Modules\Updater\Services\Core\MigrationRunnerService;   // ← ajout
9use App\Modules\Updater\Services\Core\EnvDiffService;           // ← ajout
10
11class ChangeDetectorService implements ChangeDetectorInterface
12{
13    // Fichiers/dossiers à exclure de la comparaison
14    protected array $excluded = [
15        '.env',
16        'writable/',
17        '.git/',
18        'vendor/',
19        'node_modules/',
20        'app/Config/Database.php',
21    ];
22
23    // Fichiers critiques
24    protected array $critical = [
25        'app/Config/',
26        'app/Database/Migrations/',
27        '.env',
28    ];
29
30    // Fichiers sensibles
31    protected array $sensitive = [
32        'app/Controllers/',
33        'app/Models/',
34        'app/Services/',
35        'app/Modules/',
36    ];
37
38    public function __construct(
39        protected ProgressTrackerInterface $tracker
40    ) {}
41
42    // =========================================================
43    // INTERFACE — compare()
44    // =========================================================
45
46    public function compare(array $release): ChangeDiffInterface
47    {
48        $this->tracker->set(20, 'Extraction du package');
49        $extractedPath = $this->extract();
50
51        $this->tracker->set(40, 'Scan des fichiers locaux');
52        $localFiles = $this->scanDirectory(ROOTPATH);
53
54        $this->tracker->set(60, 'Scan des fichiers du package');
55        $packageFiles = $this->scanDirectory($extractedPath);
56
57        $this->tracker->set(80, 'Comparaison en cours');
58        $diff = $this->buildDiff($localFiles, $packageFiles, $extractedPath);
59
60        $this->tracker->set(100, 'Comparaison terminée');
61
62        return $diff;
63    }
64
65    // =========================================================
66    // INTERFACE — getExcluded() / setExcluded()
67    // =========================================================
68
69    public function getExcluded(): array
70    {
71        return $this->excluded;
72    }
73
74    public function setExcluded(array $excluded): void
75    {
76        $this->excluded = $excluded;
77    }
78
79    // =========================================================
80    // INTERFACE — assessRisk()
81    // =========================================================
82
83    public function assessRisk(string $path, string $operation): string
84    {
85        foreach ($this->critical as $pattern) {
86            if (str_starts_with($path, $pattern)) {
87                return 'critical';
88            }
89        }
90
91        foreach ($this->sensitive as $pattern) {
92            if (str_starts_with($path, $pattern)) {
93                return 'medium';
94            }
95        }
96
97        return 'low';
98    }
99
100    // =========================================================
101    // INTERFACE — scanDirectory()
102    // =========================================================
103
104    public function scanDirectory(string $basePath): array
105    {
106        $basePath = rtrim($basePath, '/') . '/';
107        $files    = [];
108
109        $iterator = new \RecursiveIteratorIterator(
110            new \RecursiveDirectoryIterator(
111                $basePath,
112                \RecursiveDirectoryIterator::SKIP_DOTS
113            )
114        );
115
116        foreach ($iterator as $file) {
117            if (!$file->isFile()) {
118                continue;
119            }
120
121            $relativePath = str_replace($basePath, '', $file->getPathname());
122
123            if ($this->isExcluded($relativePath)) {
124                continue;
125            }
126
127            $files[$relativePath] = [
128                'hash'     => md5_file($file->getPathname()),
129                'size'     => $file->getSize(),
130                'modified' => $file->getMTime(),
131                'path'     => $file->getPathname(),
132            ];
133        }
134
135        return $files;
136    }
137
138    // =========================================================
139    // EXTRACTION
140    // =========================================================
141
142    protected function extract(): string
143    {
144        $zipPath       = WRITEPATH . 'updates/package.zip';
145        $extractedPath = WRITEPATH . 'updates/extracted/';
146
147        if (!file_exists($zipPath)) {
148            throw new \RuntimeException('Package zip introuvable : ' . $zipPath);
149        }
150
151        if (is_dir($extractedPath)) {
152            $this->deleteDirectory($extractedPath);
153        }
154
155        mkdir($extractedPath, 0755, true);
156
157        $zip = new \ZipArchive();
158
159        if ($zip->open($zipPath) !== true) {
160            throw new \RuntimeException('Impossible d\'ouvrir le zip');
161        }
162
163        $zip->extractTo($extractedPath);
164        $zip->close();
165
166        $entries = array_diff(scandir($extractedPath), ['.', '..']);
167
168        if (count($entries) === 1) {
169            $subDir = $extractedPath . reset($entries) . '/';
170            if (is_dir($subDir)) {
171                return $subDir;
172            }
173        }
174
175        return $extractedPath;
176    }
177
178    // =========================================================
179    // COMPARAISON (version révisée)
180    // =========================================================
181
182    protected function buildDiff(
183        array  $localFiles,
184        array  $packageFiles,
185        string $extractedPath
186    ): ChangeDiffInterface {
187
188        $added     = [];
189        $updated   = [];
190        $deleted   = [];
191        $unchanged = [];
192
193        foreach ($packageFiles as $relativePath => $packageFile) {
194            if (!isset($localFiles[$relativePath])) {
195                $added[$relativePath] = [
196                    'file'         => $relativePath,
197                    'package_hash' => $packageFile['hash'],
198                    'package_size' => $packageFile['size'],
199                    'local_hash'   => null,
200                    'local_size'   => null,
201                    'risk'         => $this->assessRisk($relativePath, 'added'),
202                ];
203            } elseif ($localFiles[$relativePath]['hash'] !== $packageFile['hash']) {
204                $updated[$relativePath] = [
205                    'file'         => $relativePath,
206                    'local_hash'   => $localFiles[$relativePath]['hash'],
207                    'package_hash' => $packageFile['hash'],
208                    'local_size'   => $localFiles[$relativePath]['size'],
209                    'package_size' => $packageFile['size'],
210                    'risk'         => $this->assessRisk($relativePath, 'updated'),
211                ];
212            } else {
213                $unchanged[] = $relativePath;
214            }
215        }
216
217        foreach ($localFiles as $relativePath => $localFile) {
218            if (!isset($packageFiles[$relativePath])) {
219                $deleted[$relativePath] = [
220                    'file'       => $relativePath,
221                    'local_hash' => $localFile['hash'],
222                    'local_size' => $localFile['size'],
223                    'risk'       => $this->assessRisk($relativePath, 'deleted'),
224                ];
225            }
226        }
227
228        // ✅ Détection migrations et seeds
229        $migrationRunner = new MigrationRunnerService($this->tracker);
230        $newMigrations = $migrationRunner->getNewMigrations($extractedPath);
231        $newSeeds      = $migrationRunner->getNewSeeds($extractedPath);
232        $pendingMigrations = $migrationRunner->getPendingMigrations();
233
234        // ✅ Détection variables d'environnement
235        $envDiff        = new EnvDiffService();
236        $missingEnvVars = $envDiff->getMissingVariables();
237        $obsoleteEnvVars = $envDiff->getObsoleteVariables();
238
239        return new ChangeDiffServices(
240            hasChanges:       !empty($added) || !empty($updated) || !empty($deleted),
241            addedFiles:       array_values($added),
242            updatedFiles:     array_values($updated),
243            deletedFiles:     array_values($deleted),
244            unchanged:        $unchanged,
245            extractedPath:    $extractedPath,
246            newMigrations:    $newMigrations,
247            newSeeds:         $newSeeds,
248            pendingMigrations: $pendingMigrations,
249            missingEnvVars:   $missingEnvVars,
250            obsoleteEnvVars:  $obsoleteEnvVars,
251        );
252    }
253
254    // =========================================================
255    // HELPERS
256    // =========================================================
257
258    protected function isExcluded(string $relativePath): bool
259    {
260        foreach ($this->excluded as $pattern) {
261            if (str_starts_with($relativePath, $pattern)) {
262                return true;
263            }
264        }
265        return false;
266    }
267
268    protected function deleteDirectory(string $dir): void
269    {
270        foreach (new \RecursiveIteratorIterator(
271            new \RecursiveDirectoryIterator(
272                $dir,
273                \RecursiveDirectoryIterator::SKIP_DOTS
274            ),
275            \RecursiveIteratorIterator::CHILD_FIRST
276        ) as $file) {
277            $file->isDir()
278                ? rmdir($file->getPathname())
279                : unlink($file->getPathname());
280        }
281        rmdir($dir);
282    }
283}