• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

move-elevator / composer-translation-validator / 18560103885

16 Oct 2025 11:42AM UTC coverage: 95.519%. Remained the same
18560103885

Pull #73

github

jackd248
refactor: remove unnecessary type hint from MismatchValidator
Pull Request #73: build: add php-cs-fixer-preset

206 of 210 new or added lines in 16 files covered. (98.1%)

91 existing lines in 20 files now uncovered.

2345 of 2455 relevant lines covered (95.52%)

7.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.75
/src/FileDetector/Collector.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the "composer-translation-validator" Composer plugin.
7
 *
8
 * (c) 2025 Konrad Michalik <km@move-elevator.de>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13

14
namespace MoveElevator\ComposerTranslationValidator\FileDetector;
15

16
use Exception;
17
use MoveElevator\ComposerTranslationValidator\Parser\ParserRegistry;
18
use Psr\Log\LoggerInterface;
19
use RecursiveDirectoryIterator;
20
use RecursiveIteratorIterator;
21
use ReflectionException;
22
use Symfony\Component\Filesystem\Filesystem;
23

24
use function dirname;
25
use function in_array;
26

27
/**
28
 * Collector.
29
 *
30
 * @author Konrad Michalik <km@move-elevator.de>
31
 * @license GPL-3.0-or-later
32
 */
33
class Collector
34
{
35
    public function __construct(protected ?LoggerInterface $logger = null) {}
10✔
36

37
    /**
38
     * @param string[]      $paths
39
     * @param string[]|null $excludePatterns
40
     *
41
     * @return array<class-string, array<string, array<mixed>>>
42
     *
43
     * @throws ReflectionException
44
     */
45
    public function collectFiles(
10✔
46
        array $paths,
47
        ?DetectorInterface $detector = null,
48
        ?array $excludePatterns = null,
49
        bool $recursive = false,
50
    ): array {
51
        $allFiles = [];
10✔
52
        foreach ($paths as $path) {
10✔
53
            if (!(new Filesystem())->exists($path)) {
10✔
54
                $this->logger?->error('The provided path "'.$path.'" is not a valid directory.');
1✔
55
                continue;
1✔
56
            }
57

58
            foreach (ParserRegistry::getAvailableParsers() as $parserClass) {
9✔
59
                $files = $this->findFiles($path, $parserClass::getSupportedFileExtensions(), $recursive);
9✔
60
                if (empty($files)) {
9✔
61
                    $this->logger?->debug('No files found for parser class "'.$parserClass.'" in path "'.$path.'".');
9✔
62
                    continue;
9✔
63
                }
64

65
                if ($excludePatterns) {
6✔
66
                    $files = array_filter(
1✔
67
                        $files,
1✔
68
                        static fn ($file) => !array_filter(
1✔
69
                            $excludePatterns,
1✔
70
                            static fn ($pattern) => fnmatch($pattern, basename((string) $file)),
1✔
71
                        ),
1✔
72
                    );
1✔
73
                }
74

75
                if (empty($files)) {
6✔
UNCOV
76
                    $this->logger?->debug('No files found for parser class "'.$parserClass.'" in path "'.$path.'".');
×
UNCOV
77
                    continue;
×
78
                }
79

80
                if (null !== $detector) {
6✔
81
                    $allFiles[$parserClass][$path] = $detector->mapTranslationSet($files);
5✔
82
                } else {
83
                    // Group files by directory to prevent cross-directory FileSets
84
                    $filesByDirectory = $this->groupFilesByDirectory($files);
1✔
85

86
                    foreach ($filesByDirectory as $directory => $directoryFiles) {
1✔
87
                        foreach (FileDetectorRegistry::getAvailableFileDetectors() as $fileDetector) {
1✔
88
                            $translationSet = (new $fileDetector())->mapTranslationSet($directoryFiles);
1✔
89
                            if (!empty($translationSet)) {
1✔
90
                                // Use directory-specific path key to separate FileSets
91
                                $pathKey = $path.'/'.$directory;
1✔
92
                                $allFiles[$parserClass][$pathKey] = $translationSet;
1✔
93
                                break; // Found a detector for this directory, move to next directory
1✔
94
                            }
95
                        }
96
                    }
97
                }
98
            }
99
        }
100

101
        return $allFiles;
10✔
102
    }
103

104
    /**
105
     * Find files in a directory, optionally recursively.
106
     *
107
     * @param string[] $supportedExtensions
108
     *
109
     * @return string[]
110
     */
111
    private function findFiles(string $path, array $supportedExtensions, bool $recursive): array
9✔
112
    {
113
        if (!$recursive) {
9✔
114
            $globFiles = glob($path.'/*');
8✔
115
            if (false === $globFiles) {
8✔
UNCOV
116
                $this->logger?->warning('Failed to glob files in path: '.$path);
×
117

UNCOV
118
                return [];
×
119
            }
120

121
            return array_filter(
8✔
122
                $globFiles,
8✔
123
                static fn ($file) => in_array(
8✔
124
                    pathinfo($file, \PATHINFO_EXTENSION),
8✔
125
                    $supportedExtensions,
8✔
126
                    true,
8✔
127
                ),
8✔
128
            );
8✔
129
        }
130

131
        $normalizedPath = $this->normalizePath($path);
1✔
132
        if (!$this->isPathSafe($normalizedPath)) {
1✔
UNCOV
133
            $this->logger?->warning('Skipping potentially unsafe path: '.$path);
×
134

UNCOV
135
            return [];
×
136
        }
137

138
        $files = [];
1✔
139

140
        try {
141
            $iterator = new RecursiveIteratorIterator(
1✔
142
                new RecursiveDirectoryIterator($normalizedPath, RecursiveDirectoryIterator::SKIP_DOTS),
1✔
143
                RecursiveIteratorIterator::LEAVES_ONLY,
1✔
144
            );
1✔
145

146
            foreach ($iterator as $file) {
1✔
147
                $filePath = $file->getPathname();
1✔
148
                $extension = pathinfo((string) $filePath, \PATHINFO_EXTENSION);
1✔
149

150
                if (in_array($extension, $supportedExtensions, true) && is_file($filePath)) {
1✔
151
                    $files[] = $filePath;
1✔
152
                }
153
            }
UNCOV
154
        } catch (Exception $e) {
×
UNCOV
155
            $this->logger?->error('Error during recursive file search: '.$e->getMessage());
×
156

UNCOV
157
            return [];
×
158
        }
159

160
        return $files;
1✔
161
    }
162

163
    /**
164
     * Normalize a file path to prevent path traversal attacks.
165
     */
166
    private function normalizePath(string $path): string
1✔
167
    {
168
        $resolved = realpath($path);
1✔
169
        if (false !== $resolved) {
1✔
170
            return $resolved;
1✔
171
        }
172

UNCOV
173
        return rtrim($path, '/\\');
×
174
    }
175

176
    /**
177
     * Basic path safety check to prevent obvious security issues.
178
     */
179
    private function isPathSafe(string $path): bool
1✔
180
    {
181
        $dangerousPaths = ['/etc', '/usr', '/bin', '/sbin', '/proc', '/sys', '/private/etc'];
1✔
182

183
        foreach ($dangerousPaths as $dangerousPath) {
1✔
184
            if (str_starts_with($path, $dangerousPath)) {
1✔
UNCOV
185
                return false;
×
186
            }
187
        }
188

189
        return substr_count($path, '/') + substr_count($path, '\\') <= 20;
1✔
190
    }
191

192
    /**
193
     * Groups files by their immediate parent directory to prevent cross-directory FileSets.
194
     *
195
     * @param array<string> $files
196
     *
197
     * @return array<string, array<string>>
198
     */
199
    private function groupFilesByDirectory(array $files): array
1✔
200
    {
201
        $groups = [];
1✔
202

203
        foreach ($files as $file) {
1✔
204
            $directory = dirname($file);
1✔
205
            $groups[$directory][] = $file;
1✔
206
        }
207

208
        return $groups;
1✔
209
    }
210
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc