diff --git a/CHANGELOG.md b/CHANGELOG.md index c6dcdef..e3021ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: run manager commands in the configured root directory without changing the PHP working directory, and prevent manager probes and npm dependency cleanup when manager execution is disabled. - feat!: add secure frontend audits with CVE reporting, CI formats, and strict npm, pnpm, Yarn, and Bun validation. - perf: write generated Composer asset manifests directly without first copying their source files. +- fix: detect generated asset manifest write failures before running the frontend manager. ## 0.2.0 January 24, 2026 diff --git a/src/Solver/Solver.php b/src/Solver/Solver.php index 0330468..f854e0d 100644 --- a/src/Solver/Solver.php +++ b/src/Solver/Solver.php @@ -213,6 +213,16 @@ private function getMockPackagePath(PackageInterface $package, string $assetDir, $targetJsonFile->write($packageValue); + try { + $targetJsonFile->read(); + } catch (Throwable $exception) { + throw new RuntimeException( + sprintf('Unable to write asset manifest "%s".', $newFilename), + 0, + $exception, + ); + } + return [$packageName, $newFilename]; } diff --git a/tests/Solver/SolverTest.php b/tests/Solver/SolverTest.php index c26b2f3..02eecf0 100644 --- a/tests/Solver/SolverTest.php +++ b/tests/Solver/SolverTest.php @@ -8,6 +8,7 @@ use Composer\EventDispatcher\EventDispatcher; use Composer\Installer\InstallationManager; use Composer\IO\IOInterface; +use Composer\Json\JsonFile; use Composer\Package\{Link, PackageInterface, RootPackageInterface}; use Composer\Repository\{InstalledArrayRepository, RepositoryManager, WritableRepositoryInterface}; use Composer\Semver\Constraint\Constraint; @@ -20,11 +21,13 @@ use Foxy\Fallback\FallbackInterface; use Foxy\FoxyEvents; use Foxy\Solver\{Solver, SolverInterface}; +use JsonException; use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use ReflectionClass; +use Seld\JsonLint\ParsingException; use Xepozz\InternalMocker\MockerState; use function chdir; @@ -824,6 +827,121 @@ public function testSolveRejectsWhitespaceOnlyAssetDirectory(): void $solver->solve($this->composer, $this->io); } + public function testSolveRestoresComposerWhenAssetManifestCannotBeRead(): void + { + $sourceContent = '{"name":"source-package","version":"1.0.0"}'; + ['source' => $source, 'target' => $target] = $this->prepareAssetPackageManifest($sourceContent); + + MockerState::addCondition( + 'Foxy\\Solver', + 'file_get_contents', + [$source, false, null, 0, null], + false, + ); + + $this->manager->expects(self::never())->method('addDependencies'); + $this->manager->expects(self::never())->method('run'); + $this->composerFallback->expects(self::once())->method('restore'); + + try { + $this->solver->solve($this->composer, $this->io); + self::fail('Expected reading the asset manifest to fail.'); + } catch (RuntimeException $exception) { + self::assertSame(sprintf('Unable to read asset manifest "%s".', $source), $exception->getMessage()); + } + + self::assertSame($sourceContent, file_get_contents($source)); + self::assertFileDoesNotExist($target); + } + + public function testSolveRestoresComposerWhenAssetManifestCannotBeWritten(): void + { + $sourceContent = '{"name":"source-package","version":"1.0.0"}'; + ['source' => $source, 'target' => $target] = $this->prepareAssetPackageManifest($sourceContent); + $targetContent = JsonFile::encode( + ['name' => '@composer-asset/foo--bar', 'version' => '1.0.0'], + ) . "\n"; + + MockerState::addCondition( + 'Composer\\Json', + 'file_put_contents', + [$target, $targetContent, 0, null], + false, + ); + + $this->manager->expects(self::never())->method('addDependencies'); + $this->manager->expects(self::never())->method('run'); + $this->composerFallback->expects(self::once())->method('restore'); + + try { + $this->solver->solve($this->composer, $this->io); + self::fail('Expected writing the asset manifest to fail.'); + } catch (RuntimeException $exception) { + self::assertSame(sprintf('Unable to write asset manifest "%s".', $target), $exception->getMessage()); + } + + self::assertSame($sourceContent, file_get_contents($source)); + self::assertFileDoesNotExist($target); + } + + public function testSolveRestoresComposerWhenAssetManifestContainsInvalidJson(): void + { + $sourceContent = '{'; + ['source' => $source, 'target' => $target] = $this->prepareAssetPackageManifest($sourceContent); + + $this->manager->expects(self::never())->method('addDependencies'); + $this->manager->expects(self::never())->method('run'); + $this->composerFallback->expects(self::once())->method('restore'); + + try { + $this->solver->solve($this->composer, $this->io); + self::fail('Expected decoding the asset manifest to fail.'); + } catch (JsonException $exception) { + self::assertSame('Syntax error', $exception->getMessage()); + } + + self::assertSame($sourceContent, file_get_contents($source)); + self::assertFileDoesNotExist($target); + } + + public function testSolveRestoresComposerWhenAssetManifestIsTruncated(): void + { + $sourceContent = '{"name":"source-package","version":"1.0.0"}'; + ['source' => $source, 'target' => $target] = $this->prepareAssetPackageManifest($sourceContent); + $targetContent = JsonFile::encode( + ['name' => '@composer-asset/foo--bar', 'version' => '1.0.0'], + ) . "\n"; + $truncatedTargetContent = substr($targetContent, 0, -2); + + MockerState::addCondition( + 'Composer\\Json', + 'file_put_contents', + [$target, $targetContent, 0, null], + static function (string $filename, mixed $data, int $flags, mixed $context): false { + self::assertNotFalse( + file_put_contents($filename, substr((string) $data, 0, -2), $flags, $context), + ); + + return false; + }, + ); + + $this->manager->expects(self::never())->method('addDependencies'); + $this->manager->expects(self::never())->method('run'); + $this->composerFallback->expects(self::once())->method('restore'); + + try { + $this->solver->solve($this->composer, $this->io); + self::fail('Expected validating the generated asset manifest to fail.'); + } catch (RuntimeException $exception) { + self::assertSame(sprintf('Unable to write asset manifest "%s".', $target), $exception->getMessage()); + self::assertInstanceOf(ParsingException::class, $exception->getPrevious()); + } + + self::assertSame($sourceContent, file_get_contents($source)); + self::assertSame($truncatedTargetContent, file_get_contents($target)); + } + public function testSolveRestoresComposerWhenManagerThrows(): void { $this->addInstalledPackages(); @@ -1045,4 +1163,38 @@ private function invokeSolverMethodOn(SolverInterface $solver, string $method, m { return (new ReflectionClass($solver))->getMethod($method)->invoke($solver, ...$arguments); } + + /** + * @return array{source: string, target: string} + */ + private function prepareAssetPackageManifest(string $sourceContent): array + { + $package = $this->createMock(PackageInterface::class); + $package->method('getName')->willReturn('foo/bar'); + $package + ->method('getRequires') + ->willReturn([new Link('root/package', 'php-forge/foxy', new Constraint('=', '1.0.0'))]); + + $this->addInstalledPackages([$package]); + + $installPath = "{$this->cwd}/vendor/foo/bar"; + $source = "{$installPath}/package.json"; + + $this->im->expects(self::once())->method('getInstallPath')->with($package)->willReturn($installPath); + $this->manager->expects(self::once())->method('getPackageName')->willReturn('package.json'); + $this->sfs->mkdir($installPath); + + self::assertNotFalse(file_put_contents($source, $sourceContent)); + + $source = realpath($source); + + if (false === $source) { + self::fail('Unable to resolve the source asset manifest.'); + } + + return [ + 'source' => $this->fs->normalizePath($source), + 'target' => $this->getCanonicalPath('/composer-asset-dir/foo/bar/package.json'), + ]; + } } diff --git a/tests/Support/InternalMockerExtension.php b/tests/Support/InternalMockerExtension.php index 137023e..909193a 100644 --- a/tests/Support/InternalMockerExtension.php +++ b/tests/Support/InternalMockerExtension.php @@ -40,6 +40,10 @@ public function notify(Finished $event): void public static function load(): void { $mocks = [ + [ + 'namespace' => 'Composer\\Json', + 'name' => 'file_put_contents', + ], [ 'namespace' => 'Foxy\\Asset', 'name' => 'getcwd',