Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 0 additions & 16 deletions .travis.yml

This file was deleted.

7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
12 changes: 9 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -30,7 +31,12 @@
},
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
"dev-master": "2.x-dev"
}
},
"config": {
"allow-plugins": {
"kylekatarnls/update-helper": false
}
}
}
9 changes: 9 additions & 0 deletions src/Exceptions/HCLParseException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace JordJD\HCLParser\Exceptions;

use RuntimeException;

class HCLParseException extends RuntimeException
{
}
61 changes: 37 additions & 24 deletions src/HCLParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,17 @@

namespace JordJD\HCLParser;

/**
* Class HCLParser.
*/
use JordJD\HCLParser\Exceptions\HCLParseException;

class HCLParser
{
/**
* @var
*/
private $hcl;

/**
* HCLParser constructor.
*
* @param $hcl
*/
public function __construct($hcl)
{
$this->hcl = $hcl;
}

/**
* @return string
*/
private function getBinaryPath()
{
$binaryPath = __DIR__.'/../bin/'.Installer::getBinaryFilename();
Expand All @@ -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;
}
}
125 changes: 79 additions & 46 deletions src/Installer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
}
}
14 changes: 14 additions & 0 deletions tests/Unit/BasicUsageTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use JordJD\HCLParser\HCLParser;
use JordJD\HCLParser\Exceptions\HCLParseException;
use PHPUnit\Framework\TestCase;

class BasicUsageTest extends TestCase
Expand All @@ -14,4 +15,17 @@ public function testBasicParsing()

$this->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);
}
}
}
4 changes: 4 additions & 0 deletions tests/Unit/InstallerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
}
}