Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
56 / 60
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
RollbackService
93.22% covered (success)
93.22%
55 / 59
50.00% covered (danger)
50.00%
2 / 4
23.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createPreUpdateBackup
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
7.02
 restoreFromBackup
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
12.08
 identifyNewFiles
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Updater\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Updater\Dto\UpdateManifestDto;
12use App\Core\Updater\Exception\UpdaterException;
13use App\Core\Updater\Security\ZipSecurityGuard;
14use Yiisoft\Files\FileHelper;
15use ZipArchive;
16
17/**
18 * Service managing pre-update file backups and rollback execution upon update failure.
19 *
20 * @package App\Core\Updater\Service
21 */
22final class RollbackService
23{
24    /**
25     * RollbackService constructor.
26     *
27     * @param string $projectRoot Absolute path to application root.
28     * @param ZipSecurityGuard $securityGuard Path security guard.
29     */
30    public function __construct(
31        private readonly string $projectRoot,
32        private readonly ZipSecurityGuard $securityGuard = new ZipSecurityGuard()
33    ) {
34    }
35
36    /**
37     * Creates a rollback backup ZIP archive containing all files targeted for modification or deletion.
38     *
39     * @param UpdateManifestDto $manifest Manifest describing update changes.
40     * @param string $backupDir Directory where backup ZIP will be saved.
41     * @return string Path to the generated backup ZIP file.
42     * @throws UpdaterException If backup archive cannot be written.
43     */
44    public function createPreUpdateBackup(UpdateManifestDto $manifest, string $backupDir): string
45    {
46        FileHelper::ensureDirectory($backupDir);
47
48        $backupFileName = sprintf('pre_update_backup_%s_%s.zip', $manifest->version, date('YmdHis'));
49        $backupPath = rtrim($backupDir, '/\\') . '/' . $backupFileName;
50
51        $zip = new ZipArchive();
52        if ($zip->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
53            throw new UpdaterException(sprintf('Failed to create rollback backup archive at: %s', $backupPath));
54        }
55
56        $filesToBackup = [];
57        foreach ($manifest->filesAddOrUpdate as $item) {
58            $filesToBackup[] = $item['target_path'];
59        }
60        foreach ($manifest->filesDelete as $delPath) {
61            $filesToBackup[] = $delPath;
62        }
63
64        $uniqueFiles = array_unique($filesToBackup);
65        $backedUpCount = 0;
66
67        foreach ($uniqueFiles as $relPath) {
68            $absPath = $this->securityGuard->assertSafePath($this->projectRoot, $relPath);
69            if (file_exists($absPath) && is_file($absPath)) {
70                $zip->addFile($absPath, str_replace('\\', '/', $relPath));
71                $backedUpCount++;
72            }
73        }
74
75        // Embed metadata about new files (to delete during rollback)
76        $metaData = [
77            'version' => $manifest->version,
78            'created_at' => date('c'),
79            'backed_up_count' => $backedUpCount,
80            'files_to_remove_on_rollback' => $this->identifyNewFiles($manifest),
81        ];
82
83        $zip->addFromString('rollback_manifest.json', (string) json_encode($metaData, JSON_PRETTY_PRINT));
84        $zip->close();
85
86        return $backupPath;
87    }
88
89    /**
90     * Restores files from backup archive and cleans up newly introduced files.
91     *
92     * @param string $backupPath Path to the pre-update backup ZIP.
93     * @throws UpdaterException If archive cannot be opened or read.
94     */
95    public function restoreFromBackup(string $backupPath): void
96    {
97        if (!file_exists($backupPath) || !is_readable($backupPath)) {
98            throw new UpdaterException(sprintf('Rollback backup archive not found: %s', $backupPath));
99        }
100
101        $zip = new ZipArchive();
102        if ($zip->open($backupPath, ZipArchive::RDONLY) !== true) {
103            throw new UpdaterException(sprintf('Failed to open rollback archive: %s', $backupPath));
104        }
105
106        $newFilesToRemove = [];
107        $metaJson = $zip->getFromName('rollback_manifest.json');
108        if (is_string($metaJson) && trim($metaJson) !== '') {
109            /** @var array{files_to_remove_on_rollback?: list<string>} $decoded */
110            $decoded = json_decode($metaJson, true);
111            $newFilesToRemove = $decoded['files_to_remove_on_rollback'] ?? [];
112        }
113
114        // Restore backed up files
115        $numFiles = $zip->numFiles;
116        for ($i = 0; $i < $numFiles; $i++) {
117            $entry = $zip->statIndex($i);
118            if ($entry === false || $entry['name'] === 'rollback_manifest.json') {
119                continue;
120            }
121
122            $entryName = $entry['name'];
123            $destPath = $this->securityGuard->assertSafePath($this->projectRoot, $entryName);
124
125            FileHelper::ensureDirectory(dirname($destPath));
126            file_put_contents($destPath, (string) $zip->getFromIndex($i));
127        }
128
129        $zip->close();
130
131        // Delete newly created files that did not exist before update
132        foreach ($newFilesToRemove as $newFile) {
133            $absPath = $this->securityGuard->assertSafePath($this->projectRoot, $newFile);
134            if (file_exists($absPath) && is_file($absPath)) {
135                FileHelper::unlink($absPath);
136            }
137        }
138    }
139
140    /**
141     * Identifies which files from manifest did not exist in project root prior to update.
142     *
143     * @return list<string>
144     */
145    private function identifyNewFiles(UpdateManifestDto $manifest): array
146    {
147        $newFiles = [];
148        foreach ($manifest->filesAddOrUpdate as $item) {
149            $relPath = $item['target_path'];
150            $absPath = $this->securityGuard->assertSafePath($this->projectRoot, $relPath);
151            if (!file_exists($absPath)) {
152                $newFiles[] = $relPath;
153            }
154        }
155
156        return $newFiles;
157    }
158}