From f0b93591a1533e6b41c399877a1b457a5f099587 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Thu, 17 Sep 2026 19:08:43 -0300 Subject: [PATCH] fix(scanner): follow symlinked source directories Composer path repositories install a package as a symlink, so a source directory reached through one contributed no files at all: PHP's RecursiveDirectoryIterator does not descend into symlinked directories unless asked, because RecursiveIteratorIterator calls hasChildren() and its $allowLinks argument defaults to false. The build still reported success, and the missing classes only surfaced at runtime as `class 'X' is undefined`. Scan with FilesystemIterator::FOLLOW_SYMLINKS. A linked directory is an ordinary source entry with no setting of its own; what is compiled stays with `sources` and `ignore`. Exclusions match the path that reached a file, the one the project wrote and the scanner traversed. An `ignore` entry is no longer resolved to its link target, which compared a real path against a linked one and so never matched. Real paths are kept for identity alone. A link is refused when it closes the branch that reached it, so a link to one of its own ancestors cannot recurse; files reachable through several links are reduced to one path after exclusions have run, since deduplicating before that would drop an allowed alias along with an excluded one. --- README-CN.md | 4 +- README.md | 5 +- docs/en/COMPILER_CLI.md | 15 +++ docs/zh-cn/COMPILER_CLI.md | 14 +++ phpunit/src/Build/FileScannerTest.php | 175 ++++++++++++++++++++++++++ src/Build/FileScanner.php | 93 +++++++++++++- src/Translator.php | 56 ++++++++- 7 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 phpunit/src/Build/FileScannerTest.php diff --git a/README-CN.md b/README-CN.md index a7efaff2..dc61ae7f 100644 --- a/README-CN.md +++ b/README-CN.md @@ -331,7 +331,9 @@ ext-deps: 路径以 YAML 文件所在目录为基准。source 可以是文件或目录;条件 source 支持 `PHP_VERSION`、`PHP_VERSION_ID` 和 `PHP_OS_FAMILY`。命令行参数优先于 YAML -中的同名配置。原生链接依赖应写入 `link-libs`;`ext-deps` 会生成 +中的同名配置。扫描源码目录时会进入符号链接指向的目录,因此通过 Composer path +仓库安装的依赖(以符号链接方式安装)会像其他源码一样被编译;如需排除,请在 +`ignore` 中按访问该目录所用的路径书写。原生链接依赖应写入 `link-libs`;`ext-deps` 会生成 `ZEND_MOD_REQUIRED`,缺少所需 PHP 扩展时由 Zend 拒绝加载模块。 通用的 `objects` 列表会把已有 `.o`/`.obj` 文件直接加入链接步骤。TypePHP 不会重新编译这些文件;原生编译器、目标架构、编译参数和增量构建均由项目负责。 diff --git a/README.md b/README.md index b78f5bbd..cc8da60e 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,10 @@ ext-deps: Paths are resolved relative to the YAML file. A source entry may be a file or directory; conditional entries support `PHP_VERSION`, `PHP_VERSION_ID`, and -`PHP_OS_FAMILY`. CLI arguments override their YAML counterparts. Native linker +`PHP_OS_FAMILY`. CLI arguments override their YAML counterparts. Scanning a +source directory descends into symlinked directories, so a dependency installed +by a Composer path repository -- which is a symlink -- is compiled like any +other source; `ignore` excludes it, written as the path that reaches it. Native linker dependencies belong in `link-libs`; `ext-deps` writes `ZEND_MOD_REQUIRED` entries so Zend can reject loading when a required PHP extension is missing. The generic `objects` list adds existing `.o`/`.obj` files directly to the diff --git a/docs/en/COMPILER_CLI.md b/docs/en/COMPILER_CLI.md index a71df34f..a602ca36 100644 --- a/docs/en/COMPILER_CLI.md +++ b/docs/en/COMPILER_CLI.md @@ -117,6 +117,21 @@ Corresponding long options: When a `project.yml` is passed, command-line arguments take precedence over same-named settings in the YAML. For the project file format, see the user documentation and the project configuration parser in the code. +### Symlinked source directories + +Scanning a source directory descends into symlinked directories, so a dependency +installed by a Composer path repository -- which is installed as a symlink -- is +compiled like any other source. A link pointing at one of its own ancestors does +not recurse, and a file reached through more than one link is compiled once. + +Excluding one is the ordinary `ignore` entry, written as the path that reaches +it rather than the path it points at: + +```yaml +ignore: + - vendor/vendor/mylib +``` + ### Precompiled object files A project can add object files produced by an external native toolchain as diff --git a/docs/zh-cn/COMPILER_CLI.md b/docs/zh-cn/COMPILER_CLI.md index d52f0998..b03d9131 100644 --- a/docs/zh-cn/COMPILER_CLI.md +++ b/docs/zh-cn/COMPILER_CLI.md @@ -117,6 +117,20 @@ TypePHP 和 PHPX 的最低运行时版本均为 PHP 8.4。`--php-version` 与实 传入 `project.yml` 时,命令行参数优先于 YAML 中的同名配置。项目文件格式参见用户文档及代码中的项目配置解析器。 +### 符号链接的源码目录 + +扫描源码目录时会进入符号链接指向的目录,因此通过 Composer path 仓库安装的依赖 +(以符号链接方式安装)会像其他源码一样被编译。指向自身上级目录的链接不会无限 +递归;通过多个链接都能访问到的文件只会被编译一次。 + +如需排除,使用常规的 `ignore` 配置,并按访问该目录所用的路径书写,而不是链接 +指向的目标路径: + +```yaml +ignore: + - vendor/vendor/mylib +``` + ### 预编译对象文件 项目可以把外部工具链生成的对象文件作为通用链接输入: diff --git a/phpunit/src/Build/FileScannerTest.php b/phpunit/src/Build/FileScannerTest.php new file mode 100644 index 00000000..5f47a8c0 --- /dev/null +++ b/phpunit/src/Build/FileScannerTest.php @@ -0,0 +1,175 @@ +root = realpath(sys_get_temp_dir()) . '/typephp-file-scanner-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/project/src', 0777, true); + mkdir($this->root . '/outside/src', 0777, true); + file_put_contents($this->root . '/project/src/Own.php', "root . '/outside/src/Linked.php', "removeDirectory($this->root); + } + + /** + * A Composer path repository installs a package as a symlink, so the files + * behind one have to be scanned like any other source. + */ + public function testFollowsSymlinkedDirectory(): void + { + symlink($this->root . '/outside', $this->root . '/project/vendor-link'); + + $files = (new FileScanner($this->root . '/project'))->scan(); + + $this->assertSame([ + $this->root . '/project/src/Own.php', + $this->root . '/project/vendor-link/src/Linked.php', + ], $files); + } + + public function testFollowsSymlinkedFile(): void + { + symlink($this->root . '/outside/src/Linked.php', $this->root . '/project/src/Linked.php'); + + $files = (new FileScanner($this->root . '/project'))->scan(); + + $this->assertSame([ + $this->root . '/project/src/Linked.php', + $this->root . '/project/src/Own.php', + ], $files); + } + + /** + * A link pointing at one of its own ancestors must not recurse forever. + */ + public function testSymlinkCycleIsScannedOnce(): void + { + symlink($this->root . '/project', $this->root . '/project/src/loop'); + + $files = (new FileScanner($this->root . '/project'))->scan(); + + $this->assertSame([$this->root . '/project/src/Own.php'], $files); + } + + /** + * Two links to one directory are one source file seen twice, and compiling + * it twice would define its symbols twice. + */ + public function testAliasesOfOneFileAreScannedOnce(): void + { + symlink($this->root . '/outside', $this->root . '/project/link-a'); + symlink($this->root . '/outside', $this->root . '/project/link-b'); + + $files = (new FileScanner($this->root . '/project'))->scan(); + + $this->assertSame([ + $this->root . '/project/link-a/src/Linked.php', + $this->root . '/project/src/Own.php', + ], $files); + } + + /** + * Excluding one alias says nothing about the other. Deduplicating before + * exclusions would drop the allowed path along with the excluded one. + */ + public function testExcludingOneAliasKeepsTheOther(): void + { + symlink($this->root . '/outside', $this->root . '/project/link-a'); + symlink($this->root . '/outside', $this->root . '/project/link-b'); + + foreach (['link-a' => 'link-b', 'link-b' => 'link-a'] as $excluded => $kept) { + $files = (new FileScanner($this->root . '/project')) + ->addExcludePattern($this->root . '/project/' . $excluded . '/src/*') + ->scan(); + + $this->assertSame([ + $this->root . '/project/' . $kept . '/src/Linked.php', + $this->root . '/project/src/Own.php', + ], $files, "excluding {$excluded} must not hide {$kept}"); + } + } + + /** + * `ignore` names the path that reaches a directory, not the path it points + * at, so the scanned path is what an entry has to be compared against. + */ + public function testYamlIgnoreExcludesLinkedDirectory(): void + { + symlink($this->root . '/outside', $this->root . '/project/vendor-link'); + file_put_contents( + $this->root . '/project/project.yml', + "name: demo\nsources:\n - .\nignore:\n - vendor-link\n", + ); + + $this->assertSame( + [$this->root . '/project/src/Own.php'], + $this->scanProject($this->root . '/project/project.yml'), + ); + } + + /** + * An entry that names the link target leaves the linked path compiled: it + * describes a directory the scan never reached. + */ + public function testYamlIgnoreOfTheLinkTargetLeavesTheLinkedPath(): void + { + symlink($this->root . '/outside', $this->root . '/project/vendor-link'); + file_put_contents( + $this->root . '/project/project.yml', + "name: demo\nsources:\n - .\nignore:\n - ../outside\n", + ); + + $this->assertSame([ + $this->root . '/project/src/Own.php', + $this->root . '/project/vendor-link/src/Linked.php', + ], $this->scanProject($this->root . '/project/project.yml')); + } + + /** @return list */ + private function scanProject(string $projectFile): array + { + $compiler = CompilerTest::create(dirname($projectFile)); + $reflection = new \ReflectionClass($compiler); + + $parse = $reflection->getMethod('parseProjectYaml'); + $filter = $reflection->getMethod('filterIgnoredFiles'); + + return array_values($filter->invoke($compiler, $parse->invoke($compiler, $projectFile))); + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + foreach (scandir($directory) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . '/' . $entry; + if (is_link($path) || !is_dir($path)) { + unlink($path); + continue; + } + + $this->removeDirectory($path); + } + + rmdir($directory); + } +} diff --git a/src/Build/FileScanner.php b/src/Build/FileScanner.php index ee716ded..9baa6856 100644 --- a/src/Build/FileScanner.php +++ b/src/Build/FileScanner.php @@ -84,9 +84,7 @@ public function getDirectory(): string public function scan(): array { $files = []; - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS) - ); + $iterator = new \RecursiveIteratorIterator($this->createDirectoryIterator()); foreach ($iterator as $file) { if ($file->isFile()) { @@ -106,7 +104,94 @@ public function scan(): array // keep both generated code and cache classification deterministic. sort($files, SORT_STRING); - return $files; + return $this->deduplicateByRealPath($files); + } + + /** + * Descend into symlinked directories. + * + * RecursiveDirectoryIterator does not follow them unless asked to: + * RecursiveIteratorIterator calls hasChildren(), whose $allowLinks argument + * defaults to false. Composer path repositories install a package as a + * symlink, so without FOLLOW_SYMLINKS a source directory that lives behind + * one contributes no files at all. + */ + private function createDirectoryIterator(): \RecursiveIterator + { + $iterator = new \RecursiveDirectoryIterator( + $this->directory, + \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS, + ); + + return new \RecursiveCallbackFilterIterator( + $iterator, + fn(\SplFileInfo $entry): bool => !$this->closesLoop($entry), + ); + } + + /** + * Whether descending into an entry would repeat the branch that reached it, + * which is what a link pointing at one of its own ancestors does. Only a + * link can: every other directory resolves below the one holding it. + * + * The branch is the whole test, never a set of everything visited so far. + * Two links to one directory are two ordinary source paths, and either of + * them may be the one a project excludes, so which of the two is the same + * file is decided after exclusions instead, by real path. + */ + private function closesLoop(\SplFileInfo $entry): bool + { + if (!$entry->isLink() || !$entry->isDir()) { + return false; + } + + $target = $entry->getRealPath(); + if ($target === false) { + return false; + } + + $ancestor = dirname($entry->getPathname()); + while (true) { + if (realpath($ancestor) === $target) { + return true; + } + if ($ancestor === $this->directory) { + return false; + } + $parent = dirname($ancestor); + if ($parent === $ancestor) { + return false; + } + $ancestor = $parent; + } + } + + /** + * One file reachable through several links is still one source file, and + * compiling it twice would define its symbols twice. + * + * Exclusions have already run, so a path the project excluded can never be + * the alias that survives here. The list is sorted, so which alias survives + * does not depend on directory-entry order. + * + * @param list $files + * @return list + */ + private function deduplicateByRealPath(array $files): array + { + $unique = []; + $seen = []; + + foreach ($files as $file) { + $identity = realpath($file) ?: $file; + if (isset($seen[$identity])) { + continue; + } + $seen[$identity] = true; + $unique[] = $file; + } + + return $unique; } private function isExcluded(string $filePath): bool diff --git a/src/Translator.php b/src/Translator.php index b9c3bb15..aa0a58ec 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3768,6 +3768,45 @@ protected function resolvePath(string $path, string $baseDir, string $label = 'P return $baseDir . '/' . $path; } + /** + * Resolve `.` and `..` without touching the filesystem. + * + * realpath() would do this too, but it also follows every symlink on the + * way, which is exactly what a path compared against a scanned one must + * not do. + */ + protected function normalizeLexicalPath(string $path): string + { + $prefix = ''; + if (preg_match('/^[A-Za-z]:/', $path) === 1) { + $prefix = substr($path, 0, 2); + $path = substr($path, 2); + } + if (DIRECTORY_SEPARATOR === '\\') { + $path = str_replace('\\', '/', $path); + } + + $absolute = str_starts_with($path, '/'); + $segments = []; + foreach (explode('/', $path) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + if ($segment === '..' && $segments !== [] && end($segments) !== '..') { + array_pop($segments); + continue; + } + $segments[] = $segment; + } + + $normalized = ($absolute ? '/' : '') . implode('/', $segments); + if ($prefix !== '') { + $normalized = $prefix . $normalized; + } + + return str_replace('/', DIRECTORY_SEPARATOR, $normalized); + } + protected function isAbsolutePath(string $path): bool { return $path !== '' @@ -4044,14 +4083,25 @@ protected function parseProjectYaml(string $path): array $this->error('`ignore` must be array'); } foreach ($ignore as $src) { - $realPath = $this->getAbsolutePath($src, $projectDir); - if (!$realPath) { + $path = $this->normalizeLexicalPath($this->resolvePath($src, $projectDir, 'Ignore path')); + if (!file_exists($path)) { // Ignore entries describe optional exclusions. Projects often // share one configuration across dependency versions where an // excluded file or directory may not exist. continue; } - $this->ignorePaths[] = $realPath; + // The scanner reports the path it traversed, symlinks and all, + // so an entry naming a linked directory has to be compared as + // the project wrote it. Resolving it first would compare a real + // path against a linked one and never match. + $this->ignorePaths[] = $path; + // A source entry is resolved before it is scanned, so a project + // that links its source root traverses real paths instead. Keep + // the target as well, for that case. + $realPath = realpath($path); + if ($realPath !== false && $realPath !== $path) { + $this->ignorePaths[] = $realPath; + } } }