Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.30% covered (success)
97.30%
36 / 37
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
PackageFilterService
97.22% covered (success)
97.22%
35 / 36
80.00% covered (warning)
80.00%
4 / 5
35
0.00% covered (danger)
0.00%
0 / 1
 shouldExclude
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 matchesForbiddenPattern
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 isStorageFileToExclude
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 isForbiddenScriptOrConfig
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 isAllowedPath
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
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
11/**
12 * Service enforcing strict allowlist and denylist filtering for production packages and exports.
13 *
14 * @package App\Core\Packaging\Service
15 */
16final class PackageFilterService
17{
18    /** @var list<string> Allowed production CLI executables in bin/ directory. */
19    private const array ALLOWED_BIN_FILES = [
20        'bin/cron',
21        'bin/download-geoip',
22        'bin/geocode-records',
23        'bin/install',
24        'bin/logs',
25        'bin/mail-scanner',
26        'bin/yii',
27    ];
28
29    /** @var list<string> Root files explicitly permitted in production releases. */
30    private const array ALLOWED_ROOT_FILES = [
31        '.gitattributes',
32        '.gitignore',
33        'LICENSE.md',
34        'README.md',
35        'SECURITY.md',
36        'composer.json',
37        'composer.lock',
38    ];
39
40    /** @var list<string> Substrings and patterns that trigger immediate exclusion. */
41    private const array FORBIDDEN_PATTERNS = [
42        '.agents',
43        '.editorconfig',
44        '.gemini',
45        '.git',
46        '.github',
47        '.phpunit.cache',
48        'ENVIRONMENT.md',
49        'clover.xml',
50        'coverage_report.txt',
51        'database/test',
52        'manifest.json',
53        'modules_summary.txt',
54        'phpstan-baseline.neon',
55        'phpstan.neon',
56        'phpunit.xml',
57        'scratch',
58        'setup_dev_environment.ps1',
59        'sonar-project.properties',
60        'tests',
61        'uncovered.txt',
62        'uncovered3.txt',
63    ];
64
65    /**
66     * Determines whether relative path should be excluded from the release package.
67     *
68     * @param string $relativePath Relative file or directory path.
69     * @return bool True if the path should be excluded.
70     */
71    public function shouldExclude(string $relativePath): bool
72    {
73        $normalized = str_replace('\\', '/', trim($relativePath, '/\\'));
74        if ($normalized === '' || $normalized === '.') {
75            return false;
76        }
77
78        $isForbidden = $this->matchesForbiddenPattern($normalized)
79            || $this->isStorageFileToExclude($normalized)
80            || $this->isForbiddenScriptOrConfig($normalized);
81
82        return $isForbidden || !$this->isAllowedPath($normalized);
83    }
84
85    /**
86     * Checks if path matches any forbidden pattern or directory.
87     *
88     * @param string $path Normalized relative path.
89     * @return bool True if matches forbidden pattern.
90     */
91    private function matchesForbiddenPattern(string $path): bool
92    {
93        $firstSegment = explode('/', $path)[0];
94
95        foreach (self::FORBIDDEN_PATTERNS as $pattern) {
96            if ($firstSegment === $pattern || $path === $pattern || str_starts_with($path, $pattern . '/')) {
97                return true;
98            }
99        }
100
101        return false;
102    }
103
104    /**
105     * Checks if path belongs to runtime/storage and is not a placeholder .gitkeep file.
106     *
107     * @param string $path Normalized relative path.
108     * @return bool True if storage file should be stripped.
109     */
110    private function isStorageFileToExclude(string $path): bool
111    {
112        $isStoragePath = str_starts_with($path, 'storage/cache/')
113            || str_starts_with($path, 'storage/runtime/')
114            || str_starts_with($path, 'storage/logs/')
115            || str_starts_with($path, 'storage/documents/')
116            || str_starts_with($path, 'storage/backups/')
117            || str_starts_with($path, 'runtime/cache/');
118
119        return $isStoragePath && !str_ends_with($path, '.gitkeep');
120    }
121
122    /**
123     * Checks if path is a forbidden temporary script, test or sensitive config file.
124     *
125     * @param string $path Normalized relative path.
126     * @return bool True if forbidden.
127     */
128    private function isForbiddenScriptOrConfig(string $path): bool
129    {
130        $isSensitiveConfig = $path === 'config/common/api_auth.php'
131            || $path === 'storage/app.key'
132            || $path === 'config/.merge-plan.php';
133
134        $isScriptOrTest = str_ends_with($path, 'Test.php')
135            || str_ends_with($path, '.py')
136            || str_ends_with($path, '.ps1');
137
138        $isForbiddenBin = str_starts_with($path, 'bin/') && !in_array($path, self::ALLOWED_BIN_FILES, true);
139
140        return $isSensitiveConfig || $isScriptOrTest || $isForbiddenBin;
141    }
142
143    /**
144     * Determines whether path belongs to allowed structural directories and files.
145     *
146     * @param string $path Normalized relative path.
147     * @return bool True if explicitly allowed.
148     */
149    private function isAllowedPath(string $path): bool
150    {
151        $isExplicitFile = in_array($path, self::ALLOWED_ROOT_FILES, true)
152            || in_array($path, self::ALLOWED_BIN_FILES, true);
153
154        $isAllowedDir = str_starts_with($path, 'src/')
155            || str_starts_with($path, 'profiles/')
156            || str_starts_with($path, 'config/')
157            || str_starts_with($path, 'public/')
158            || str_starts_with($path, 'database/sql/');
159
160        $isStorageGitkeep = str_starts_with($path, 'storage/') && str_ends_with($path, '.gitkeep');
161
162        return $isExplicitFile || $isAllowedDir || $isStorageGitkeep;
163    }
164}