Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.52% covered (warning)
88.52%
54 / 61
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReleaseExporterService
88.33% covered (warning)
88.33%
53 / 60
40.00% covered (danger)
40.00%
2 / 5
20.64
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
 export
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 copyAllowedFiles
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
7.39
 sanitizeTargetComposerJson
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 ensureStorageGitkeeps
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\Packaging\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Packaging\Dto\AuditReportDto;
12use App\Core\Packaging\Exception\PackagingException;
13use RecursiveDirectoryIterator;
14use RecursiveIteratorIterator;
15use SplFileInfo;
16use Yiisoft\Files\FileHelper;
17
18/**
19 * Service orchestrating clean, zero-leak release exports from source workspace to distribution repository.
20 *
21 * @package App\Core\Packaging\Service
22 */
23final class ReleaseExporterService
24{
25    /**
26     * ReleaseExporterService constructor.
27     *
28     * @param string                $sourceRoot    Absolute path to master source workspace.
29     * @param PackageFilterService  $filterService File filter strategy.
30     * @param ReleaseAuditorService $auditor       Security and integrity auditor.
31     */
32    public function __construct(
33        private readonly string $sourceRoot,
34        private readonly PackageFilterService $filterService = new PackageFilterService(),
35        private readonly ReleaseAuditorService $auditor = new ReleaseAuditorService()
36    ) {
37    }
38
39    /**
40     * Executes end-to-end export from source to target directory with automated transformation and audit.
41     *
42     * @param string      $targetDirectory Destination directory path.
43     * @param string|null $version         Optional semantic version string to embed.
44     * @return AuditReportDto Resulting security audit report.
45     * @throws PackagingException If directory preparation fails or security audit detects violations.
46     */
47    public function export(string $targetDirectory, ?string $version = null): AuditReportDto
48    {
49        $realSource = realpath($this->sourceRoot);
50        if ($realSource === false || !is_dir($realSource)) {
51            throw new PackagingException(sprintf('Source directory does not exist: %s', $this->sourceRoot));
52        }
53
54        FileHelper::ensureDirectory($targetDirectory);
55        $realTarget = realpath($targetDirectory);
56        if ($realTarget === false) {
57            throw new PackagingException(sprintf('Target directory could not be resolved: %s', $targetDirectory));
58        }
59
60        $this->copyAllowedFiles($realSource, $realTarget);
61        $this->sanitizeTargetComposerJson($realTarget, $version);
62        $this->ensureStorageGitkeeps($realTarget);
63
64        $report = $this->auditor->auditDirectory($realTarget);
65        if (!$report->passed) {
66            $msg = sprintf(
67                "Export security audit FAILED with %d violation(s):\n - %s",
68                count($report->violations),
69                implode("\n - ", $report->violations)
70            );
71            throw new PackagingException($msg);
72        }
73
74        return $report;
75    }
76
77    /**
78     * Recursively copies files matching allowlist strategy from source to target.
79     *
80     * @param string $source Canonical source path.
81     * @param string $target Canonical target path.
82     */
83    private function copyAllowedFiles(string $source, string $target): void
84    {
85        $canonicalSource = str_replace('\\', '/', $source);
86        $iterator = new RecursiveIteratorIterator(
87            new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
88            RecursiveIteratorIterator::LEAVES_ONLY
89        );
90
91        /** @var SplFileInfo $file */
92        foreach ($iterator as $file) {
93            if (!$file->isFile()) {
94                continue;
95            }
96
97            $realPath = $file->getRealPath();
98            if ($realPath === false) {
99                continue;
100            }
101
102            $canonicalReal = str_replace('\\', '/', $realPath);
103            $relativePath = ltrim(substr($canonicalReal, strlen($canonicalSource)), '/');
104
105            // Skip output archive, git internals and vendor dependencies
106            if (str_starts_with($relativePath, 'vendor/') || str_starts_with($relativePath, '.git/')) {
107                continue;
108            }
109
110            if ($this->filterService->shouldExclude($relativePath)) {
111                continue;
112            }
113
114            $destPath = $target . '/' . $relativePath;
115            FileHelper::ensureDirectory(dirname($destPath));
116            copy($realPath, $destPath);
117        }
118    }
119
120    /**
121     * Sanitizes composer.json in target directory by stripping require-dev, autoload-dev and scripts.
122     *
123     * @param string      $targetDir Target root directory.
124     * @param string|null $version   Release version.
125     */
126    private function sanitizeTargetComposerJson(string $targetDir, ?string $version): void
127    {
128        $composerPath = $targetDir . '/composer.json';
129        if (!file_exists($composerPath)) {
130            return;
131        }
132
133        $data = json_decode((string) file_get_contents($composerPath), true);
134        if (!is_array($data)) {
135            return;
136        }
137
138        $data['name'] = 'ammonly/ammonly-prod';
139        $data['description'] = 'Ammonly Production Distribution Powered by Yii3';
140
141        unset($data['require-dev'], $data['autoload-dev'], $data['scripts']);
142
143        if ($version !== null) {
144            $data['version'] = ltrim($version, 'v');
145        }
146
147        $json = (string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
148        file_put_contents($composerPath, $json . "\n");
149    }
150
151    /**
152     * Ensures all storage subdirectories exist and contain .gitkeep placeholders.
153     *
154     * @param string $targetDir Target root directory.
155     */
156    private function ensureStorageGitkeeps(string $targetDir): void
157    {
158        $storageDirs = ['cache', 'runtime', 'logs', 'backups', 'documents'];
159        foreach ($storageDirs as $dir) {
160            $fullDir = $targetDir . '/storage/' . $dir;
161            FileHelper::ensureDirectory($fullDir);
162            $keepFile = $fullDir . '/.gitkeep';
163            if (!file_exists($keepFile)) {
164                file_put_contents($keepFile, "# Directory placeholder\n");
165            }
166        }
167    }
168}