From a241000890ba2559f1659ae2dafd0cfb892590e6 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Sat, 18 Jul 2026 03:51:16 +0100 Subject: [PATCH] Run HCL parsing without a shell --- .github/workflows/tests.yml | 28 ++++++ .travis.yml | 16 ---- README.md | 7 +- composer.json | 12 ++- src/Exceptions/HCLParseException.php | 9 ++ src/HCLParser.php | 61 ++++++++----- src/Installer.php | 125 +++++++++++++++++---------- tests/Unit/BasicUsageTest.php | 14 +++ tests/Unit/InstallerTest.php | 4 + 9 files changed, 185 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml create mode 100644 src/Exceptions/HCLParseException.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..16e571a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - php: '7.4' + composer: 'v2.2' + - php: '8.5' + composer: 'latest' + steps: + - uses: actions/checkout@v5 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:${{ matrix.composer }} + coverage: none + - run: composer validate --strict + - run: composer update --prefer-dist --no-interaction + - run: vendor/bin/phpunit diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 23db006..0000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: php -dist: trusty -php: - - '5.6' - - '7.0' - - '7.1' - - '7.2' - - 'hhvm' -os: - - linux -install: - - composer update -script: - - ./vendor/bin/phpunit --coverage-clover ./tests/Logs/clover.xml -after_script: - - php vendor/bin/php-coveralls -v \ No newline at end of file diff --git a/README.md b/README.md index c650118..3998347 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # ⚒🔀🐘 PHP HCL Parser -[![Build Status](https://travis-ci.org/Jord-JD/php-hcl-parser.svg?branch=master)](https://travis-ci.org/Jord-JD/php-hcl-parser) -[![Coverage Status](https://coveralls.io/repos/github/Jord-JD/php-hcl-parser/badge.svg?branch=master)](https://coveralls.io/github/Jord-JD/php-hcl-parser?branch=master) +[![Tests](https://github.com/Jord-JD/php-hcl-parser/actions/workflows/tests.yml/badge.svg)](https://github.com/Jord-JD/php-hcl-parser/actions/workflows/tests.yml) HCL is a configuration language make by HashiCorp. HCL files are used by several HashiCorp products, including Terraform. This library parses HCL configuration files into PHP objects. +> **Compatibility note:** The bundled `json2hcl` 0.0.6 converter supports legacy HCL syntax. Its upstream project is archived and does not provide ARM64 binaries, so this package is not suitable for modern HCL 2 syntax or ARM64 hosts. + ## Installation You can install the PHP HCL Parser library using Composer. Just run the following command @@ -26,6 +27,8 @@ $hcl = file_get_contents('example.tf'); $configObject = (new HCLParser($hcl))->parse(); ``` +Invalid HCL, converter failures, and invalid converter output throw `JordJD\HCLParser\Exceptions\HCLParseException`. Downloaded converter binaries are checked against pinned SHA-256 hashes before they are installed. + The resulting object will look similar to the following. ```php diff --git a/composer.json b/composer.json index f66ff18..334b2e2 100644 --- a/composer.json +++ b/composer.json @@ -10,10 +10,11 @@ } ], "require": { - "php": "^7.4 || ^8.0" + "php": "^7.4 || ^8.0", + "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5 || ^12.0" }, "autoload": { "psr-4": { @@ -30,7 +31,12 @@ }, "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "2.x-dev" + } + }, + "config": { + "allow-plugins": { + "kylekatarnls/update-helper": false } } } diff --git a/src/Exceptions/HCLParseException.php b/src/Exceptions/HCLParseException.php new file mode 100644 index 0000000..dc925e9 --- /dev/null +++ b/src/Exceptions/HCLParseException.php @@ -0,0 +1,9 @@ +hcl = $hcl; } - /** - * @return string - */ private function getBinaryPath() { $binaryPath = __DIR__.'/../bin/'.Installer::getBinaryFilename(); @@ -36,23 +24,48 @@ private function getBinaryPath() return $binaryPath; } - /** - * @return string - */ private function getJSONString() { - $command = $this->getBinaryPath().' --reverse <<\'EOF\''.PHP_EOL.$this->hcl.PHP_EOL.'EOF'; + $pipes = []; + $process = proc_open( + [$this->getBinaryPath(), '--reverse'], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes + ); + + if (!is_resource($process)) { + throw new HCLParseException('Unable to start the HCL parser process.'); + } + + fwrite($pipes[0], $this->hcl); + fclose($pipes[0]); + $json = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[2]); + $exitCode = proc_close($process); - exec($command, $lines); + if ($exitCode !== 0) { + throw new HCLParseException( + 'The HCL parser failed'.($error ? ': '.trim($error) : '.') + ); + } - return implode(PHP_EOL, $lines); + return $json; } - /** - * @return mixed - */ public function parse() { - return json_decode($this->getJSONString()); + $decoded = json_decode($this->getJSONString()); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new HCLParseException('The HCL parser returned invalid JSON: '.json_last_error_msg()); + } + + return $decoded; } } diff --git a/src/Installer.php b/src/Installer.php index dd7046b..94f1f0c 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -2,69 +2,102 @@ namespace JordJD\HCLParser; -/** - * Class Installer. - */ +use RuntimeException; + abstract class Installer { - /** - * json2hcl Version we want to use. - * - * @see https://github.com/kvz/json2hcl/releases - */ const JSON2HCL_VERSION = '0.0.6'; - /** - * Returns the correct binary filename according to the Operating System and Architecture. - */ + const CHECKSUMS = [ + 'json2hcl_v0.0.6_darwin_386' => '4294338d4f16a3f66013364d75ca49e9641a57722c9d93f6fd8d59ae71d1b232', + 'json2hcl_v0.0.6_darwin_amd64' => '547dfd077647a2fdd2258cb72f752c1422ca299f9c0b27501bcc133fac62451d', + 'json2hcl_v0.0.6_linux_386' => '0c988eee018e239a2360b7067508d7b196fd5e4a946ea7ac8b5e19c8e99d2f30', + 'json2hcl_v0.0.6_linux_amd64' => 'd124ed13f3538c465fcab19e6015d311d3cd56f7dc2db7609b6e72fec666482d', + 'json2hcl_v0.0.6_linux_arm' => 'c1ae560925f67942b17fe42d339b04b2cb1adc61c00dada6db8aea7e214bbe8f', + 'json2hcl_v0.0.6_windows_386.exe' => '2e157a10e4bd6b31f9f3664302facef10140a57a54a4bc776fad7d23e49a5691', + 'json2hcl_v0.0.6_windows_amd64.exe' => '33657d19f974c3e98b7df32eb77d01858498eaa81c12314dbaaba94650cc77ae', + ]; + public static function getBinaryFilename() { - // Defaults - $osString = 'linux'; - $architecture = 'amd64'; - - // We can not test alternative architectures and operating systems, so exclude from code coverage. - // @codeCoverageIgnoreStart - - // Switch architecture if needed - if (2147483647 == PHP_INT_MAX) { - $architecture = '386'; - } + $operatingSystems = [ + 'Darwin' => 'darwin', + 'Linux' => 'linux', + 'Windows' => 'windows', + ]; + $architectures = [ + 'amd64' => 'amd64', + 'x86_64' => 'amd64', + 'i386' => '386', + 'i686' => '386', + 'x86' => '386', + 'arm' => 'arm', + 'armv7' => 'arm', + 'armv7l' => 'arm', + ]; + $operatingSystem = isset($operatingSystems[PHP_OS_FAMILY]) ? $operatingSystems[PHP_OS_FAMILY] : null; + $machine = strtolower(php_uname('m')); + $architecture = isset($architectures[$machine]) ? $architectures[$machine] : null; - // Switch Operating System if needed - switch (true) { - case stristr(PHP_OS, 'DAR'): - $osString = 'darwin'; - break; - case stristr(PHP_OS, 'WIN'): - $osString = 'windows'; - break; + if ($operatingSystem === null || $architecture === null || ($operatingSystem !== 'linux' && $architecture === 'arm')) { + throw new RuntimeException('No json2hcl binary is available for '.PHP_OS_FAMILY.' '.$machine.'.'); } - // @codeCoverageIgnoreEnd - - return sprintf('json2hcl_v%s_%s_%s', self::JSON2HCL_VERSION, $osString, $architecture); + return sprintf( + 'json2hcl_v%s_%s_%s%s', + self::JSON2HCL_VERSION, + $operatingSystem, + $architecture, + $operatingSystem === 'windows' ? '.exe' : '' + ); } public static function installBinaries() { - $binaryUrls = [ - sprintf('https://github.com/kvz/json2hcl/releases/download/v%s/%s', self::JSON2HCL_VERSION, self::getBinaryFilename()), - ]; + $filename = self::getBinaryFilename(); + $expectedChecksum = self::CHECKSUMS[$filename]; + $destination = __DIR__.'/../bin/'.$filename; + + if (is_file($destination) && hash_equals($expectedChecksum, hash_file('sha256', $destination))) { + return; + } + + $url = sprintf( + 'https://github.com/kvz/json2hcl/releases/download/v%s/%s', + self::JSON2HCL_VERSION, + $filename + ); + $context = stream_context_create([ + 'http' => ['timeout' => 30], + ]); + $binary = @file_get_contents($url, false, $context); + + if ($binary === false) { + throw new RuntimeException('Unable to download json2hcl from its official GitHub release.'); + } + + if (!hash_equals($expectedChecksum, hash('sha256', $binary))) { + throw new RuntimeException('Downloaded json2hcl binary failed checksum verification.'); + } - foreach ($binaryUrls as $binaryUrl) { - $destination = __DIR__.'/../bin/'.basename($binaryUrl); + $temporaryPath = tempnam(dirname($destination), 'json2hcl-'); - // Skip if file exists - if (file_exists($destination)) { - continue; - } + if ($temporaryPath === false || file_put_contents($temporaryPath, $binary, LOCK_EX) === false) { + throw new RuntimeException('Unable to write the json2hcl binary.'); + } - // Download - file_put_contents($destination, file_get_contents($binaryUrl)); + if (PHP_OS_FAMILY !== 'Windows' && !chmod($temporaryPath, 0755)) { + @unlink($temporaryPath); + throw new RuntimeException('Unable to make the json2hcl binary executable.'); + } + + if (is_file($destination)) { + @unlink($destination); + } - // Make executable - chmod($destination, 0755); + if (!rename($temporaryPath, $destination)) { + @unlink($temporaryPath); + throw new RuntimeException('Unable to install the json2hcl binary.'); } } } diff --git a/tests/Unit/BasicUsageTest.php b/tests/Unit/BasicUsageTest.php index c0f5351..f1838a2 100644 --- a/tests/Unit/BasicUsageTest.php +++ b/tests/Unit/BasicUsageTest.php @@ -1,6 +1,7 @@ assertEquals($expected, $configObject); } + + public function testHclCannotEscapeIntoAShellCommand() + { + $marker = sys_get_temp_dir().'/php-hcl-parser-command-injection'; + @unlink($marker); + + try { + (new HCLParser("invalid\nEOF\n; touch ".$marker."\nEOF"))->parse(); + $this->fail('Invalid HCL should fail to parse.'); + } catch (HCLParseException $exception) { + $this->assertFileDoesNotExist($marker); + } + } } diff --git a/tests/Unit/InstallerTest.php b/tests/Unit/InstallerTest.php index 3544a94..d7f133f 100644 --- a/tests/Unit/InstallerTest.php +++ b/tests/Unit/InstallerTest.php @@ -32,6 +32,10 @@ private function checkBinariesAreInstalled() foreach ($expectedFiles as $expectedFile) { $this->assertContains($expectedFile, $files); + $this->assertSame( + Installer::CHECKSUMS[$expectedFile], + hash_file('sha256', __DIR__.'/../../bin/'.$expectedFile) + ); } } }