Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.21% covered (warning)
84.21%
48 / 57
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileOperationsService
83.93% covered (warning)
83.93%
47 / 56
50.00% covered (danger)
50.00%
2 / 4
21.66
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
 extractToStaging
75.00% covered (warning)
75.00%
21 / 28
0.00% covered (danger)
0.00%
0 / 1
7.77
 applyChanges
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
10.05
 cleanStaging
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
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 atomic file extraction, verification, copy, and deletion for updates.
19 *
20 * @package App\Core\Updater\Service
21 */
22final class FileOperationsService
23{
24    /**
25     * FileOperationsService constructor.
26     *
27     * @param string $projectRoot Absolute path to application root.
28     * @param ZipSecurityGuard $securityGuard Security path and checksum validator.
29     */
30    public function __construct(
31        private readonly string $projectRoot,
32        private readonly ZipSecurityGuard $securityGuard = new ZipSecurityGuard()
33    ) {
34    }
35
36    /**
37     * Extracts ZIP archive to a temporary staging directory safely.
38     *
39     * @param string $zipPath Path to the update ZIP.
40     * @param string $stagingDir Target staging directory.
41     * @throws UpdaterException If extraction fails or archive is invalid.
42     */
43    public function extractToStaging(string $zipPath, string $stagingDir): void
44    {
45        $this->securityGuard->validateArchive($zipPath);
46        FileHelper::ensureDirectory($stagingDir);
47
48        $zip = new ZipArchive();
49        if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) {
50            throw new UpdaterException(sprintf('Failed to open archive for extraction: %s', $zipPath));
51        }
52
53        $numFiles = $zip->numFiles;
54        for ($i = 0; $i < $numFiles; $i++) {
55            $entry = $zip->statIndex($i);
56            if ($entry === false) {
57                continue;
58            }
59
60            $entryName = $entry['name'];
61            $this->securityGuard->assertSafeEntryName($entryName);
62
63            $targetPath = $stagingDir . '/' . str_replace('\\', '/', $entryName);
64
65            // Handle directory entries
66            if (str_ends_with($entryName, '/')) {
67                FileHelper::ensureDirectory($targetPath);
68                continue;
69            }
70
71            FileHelper::ensureDirectory(dirname($targetPath));
72
73            $stream = $zip->getStream($entryName);
74            if ($stream === false) {
75                throw new UpdaterException(sprintf('Cannot extract archive stream: %s', $entryName));
76            }
77
78            $out = fopen($targetPath, 'wb');
79            if ($out === false) {
80                fclose($stream);
81                throw new UpdaterException(sprintf('Cannot write extracted file: %s', $targetPath));
82            }
83
84            stream_copy_to_stream($stream, $out);
85            fclose($out);
86            fclose($stream);
87        }
88
89        $zip->close();
90    }
91
92    /**
93     * Applies file changes (adds, updates, deletes) from staging directory into project root.
94     *
95     * @param string $stagingDir Path to extracted staging directory.
96     * @param UpdateManifestDto $manifest Manifest containing file lists.
97     * @return array{added_or_updated: int, deleted: int}
98     * @throws UpdaterException If file operations fail.
99     */
100    public function applyChanges(string $stagingDir, UpdateManifestDto $manifest): array
101    {
102        $addedOrUpdated = 0;
103        $deleted = 0;
104
105        // 1. Add or Update Files
106        foreach ($manifest->filesAddOrUpdate as $fileItem) {
107            $relPath = $fileItem['target_path'];
108            $stagingFile = $stagingDir . '/' . ltrim(str_replace('\\', '/', $relPath), '/');
109
110            if (!file_exists($stagingFile)) {
111                throw new UpdaterException(sprintf('Staged source file missing: %s', $relPath));
112            }
113
114            if (
115                isset($fileItem['sha256'])
116                && $fileItem['sha256'] !== ''
117                && !$this->securityGuard->verifyChecksum($stagingFile, $fileItem['sha256'])
118            ) {
119                throw new UpdaterException(sprintf('Integrity check failed for staged file: %s', $relPath));
120            }
121
122            $destPath = $this->securityGuard->assertSafePath($this->projectRoot, $relPath);
123            FileHelper::ensureDirectory(dirname($destPath));
124
125            if (!copy($stagingFile, $destPath)) {
126                throw new UpdaterException(sprintf('Failed to copy file to destination: %s', $destPath));
127            }
128
129            $addedOrUpdated++;
130        }
131
132        // 2. Delete Obsolete Files
133        foreach ($manifest->filesDelete as $delRelPath) {
134            $destPath = $this->securityGuard->assertSafePath($this->projectRoot, $delRelPath);
135            if (file_exists($destPath) && is_file($destPath)) {
136                FileHelper::unlink($destPath);
137                $deleted++;
138            }
139        }
140
141        return [
142            'added_or_updated' => $addedOrUpdated,
143            'deleted' => $deleted,
144        ];
145    }
146
147    /**
148     * Cleans up staging directory recursively.
149     */
150    public function cleanStaging(string $stagingDir): void
151    {
152        if (is_dir($stagingDir)) {
153            FileHelper::removeDirectory($stagingDir);
154        }
155    }
156}