* Jordi Boggiano * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Composer\Json; use Composer\Pcre\Preg; use Composer\Util\Filesystem; use JsonSchema\Validator; use Seld\JsonLint\JsonParser; use Seld\JsonLint\ParsingException; use Composer\Util\HttpDownloader; use Composer\IO\IOInterface; use Composer\Downloader\TransportException; /** * Reads/writes json files. * * @author Konstantin Kudryashiv * @author Jordi Boggiano */ class JsonFile { public const LAX_SCHEMA = 1; public const STRICT_SCHEMA = 2; public const AUTH_SCHEMA = 3; public const LOCK_SCHEMA = 4; /** @deprecated Use \JSON_UNESCAPED_SLASHES */ public const JSON_UNESCAPED_SLASHES = 64; /** @deprecated Use \JSON_PRETTY_PRINT */ public const JSON_PRETTY_PRINT = 128; /** @deprecated Use \JSON_UNESCAPED_UNICODE */ public const JSON_UNESCAPED_UNICODE = 256; public const COMPOSER_SCHEMA_PATH = __DIR__ . '/../../../res/composer-schema.json'; public const LOCK_SCHEMA_PATH = __DIR__ . '/../../../res/composer-lock-schema.json'; public const INDENT_DEFAULT = ' '; /** @var string */ private $path; /** @var ?HttpDownloader */ private $httpDownloader; /** @var ?IOInterface */ private $io; /** @var string */ private $indent = self::INDENT_DEFAULT; /** * Initializes json file reader/parser. * * @param string $path path to a lockfile * @param ?HttpDownloader $httpDownloader required for loading http/https json files * @param ?IOInterface $io * @throws \InvalidArgumentException */ public function __construct(string $path, ?HttpDownloader $httpDownloader = null, ?IOInterface $io = null) { $this->path = $path; if (null === $httpDownloader && Preg::isMatch('{^https?://}i', $path)) { throw new \InvalidArgumentException('http urls require a HttpDownloader instance to be passed'); } $this->httpDownloader = $httpDownloader; $this->io = $io; } public function getPath(): string { return $this->path; } /** * Checks whether json file exists. */ public function exists(): bool { return is_file($this->path); } /** * Reads json file. * * @throws ParsingException * @throws \RuntimeException * @return mixed */ public function read() { try { if ($this->httpDownloader) { $json = $this->httpDownloader->get($this->path)->getBody(); } else { if (!Filesystem::isReadable($this->path)) { throw new \RuntimeException('The file "'.$this->path.'" is not readable.'); } if ($this->io && $this->io->isDebug()) { $realpathInfo = ''; $realpath = realpath($this->path); if (false !== $realpath && $realpath !== $this->path) { $realpathInfo = ' (' . $realpath . ')'; } $this->io->writeError('Reading ' . $this->path . $realpathInfo); } $json = file_get_contents($this->path); } } catch (TransportException $e) { throw new \RuntimeException($e->getMessage(), 0, $e); } catch (\Exception $e) { throw new \RuntimeException('Could not read '.$this->path."\n\n".$e->getMessage()); } if ($json === false) { throw new \RuntimeException('Could not read '.$this->path); } $this->indent = self::detectIndenting($json); return static::parseJson($json, $this->path); } /** * Writes json file. * * @param mixed[] $hash writes hash into json file * @param int $options json_encode options * @throws \UnexpectedValueException|\Exception * @return void */ public function write(array $hash, int $options = JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) { if ($this->path === 'php://memory') { file_put_contents($this->path, static::encode($hash, $options, $this->indent)); return; } $dir = dirname($this->path); if (!is_dir($dir)) { if (file_exists($dir)) { throw new \UnexpectedValueException( realpath($dir).' exists and is not a directory.' ); } if (!@mkdir($dir, 0777, true)) { throw new \UnexpectedValueException( $dir.' does not exist and could not be created.' ); } } $retries = 3; while ($retries--) { try { $this->filePutContentsIfModified($this->path, static::encode($hash, $options, $this->indent). ($options & JSON_PRETTY_PRINT ? "\n" : '')); break; } catch (\Exception $e) { if ($retries > 0) { usleep(500000); continue; } throw $e; } } } /** * Modify file properties only if content modified * * @return int|false */ private function filePutContentsIfModified(string $path, string $content) { $currentContent = @file_get_contents($path); if (false === $currentContent || $currentContent !== $content) { return file_put_contents($path, $content); } return 0; } /** * Validates the schema of the current json file according to composer-schema.json rules * * @param int $schema a JsonFile::*_SCHEMA constant * @param string|null $schemaFile a path to the schema file * @throws JsonValidationException * @throws ParsingException * @return true true on success * * @phpstan-param self::*_SCHEMA $schema */ public function validateSchema(int $schema = self::STRICT_SCHEMA, ?string $schemaFile = null): bool { if (!Filesystem::isReadable($this->path)) { throw new \RuntimeException('The file "'.$this->path.'" is not readable.'); } $content = file_get_contents($this->path); $data = json_decode($content); if (null === $data && 'null' !== $content) { self::validateSyntax($content, $this->path); } return self::validateJsonSchema($this->path, $data, $schema, $schemaFile); } /** * Validates the schema of the current json file according to composer-schema.json rules * * @param mixed $data Decoded JSON data to validate * @param int $schema a JsonFile::*_SCHEMA constant * @param string|null $schemaFile a path to the schema file * @throws JsonValidationException * @return true true on success * * @phpstan-param self::*_SCHEMA $schema */ public static function validateJsonSchema(string $source, $data, int $schema, ?string $schemaFile = null): bool { $isComposerSchemaFile = false; if (null === $schemaFile) { if ($schema === self::LOCK_SCHEMA) { $schemaFile = self::LOCK_SCHEMA_PATH; } else { $isComposerSchemaFile = true; $schemaFile = self::COMPOSER_SCHEMA_PATH; } } // Prepend with file:// only when not using a special schema already (e.g. in the phar) if (false === strpos($schemaFile, '://')) { $schemaFile = 'file://' . $schemaFile; } $schemaData = (object) ['$ref' => $schemaFile, '$schema' => "https://json-schema.org/draft-04/schema#"]; if ($schema === self::STRICT_SCHEMA && $isComposerSchemaFile) { $schemaData = json_decode((string) file_get_contents($schemaFile)); $schemaData->additionalProperties = false; $schemaData->required = ['name', 'description']; } elseif ($schema === self::AUTH_SCHEMA && $isComposerSchemaFile) { $schemaData = (object) ['$ref' => $schemaFile.'#/properties/config', '$schema' => "https://json-schema.org/draft-04/schema#"]; } $validator = new Validator(); // convert assoc arrays to objects $data = json_decode((string) json_encode($data)); $validator->validate($data, $schemaData); if (!$validator->isValid()) { $errors = []; foreach ($validator->getErrors() as $error) { $errors[] = ($error['property'] ? $error['property'].' : ' : '').$error['message']; } throw new JsonValidationException('"'.$source.'" does not match the expected JSON schema', $errors); } return true; } /** * Encodes an array into (optionally pretty-printed) JSON * * @param mixed $data Data to encode into a formatted JSON string * @param int $options json_encode options (defaults to JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) * @param string $indent Indentation string * @return string Encoded json */ public static function encode($data, int $options = 448, string $indent = self::INDENT_DEFAULT): string { $json = json_encode($data, $options); if (false === $json) { self::throwEncodeError(json_last_error()); } if (($options & JSON_PRETTY_PRINT) > 0 && $indent !== self::INDENT_DEFAULT ) { // Pretty printing and not using default indentation return Preg::replaceCallback( '#^ {4,}#m', static function ($match) use ($indent): string { return str_repeat($indent, (int)(strlen($match[0]) / 4)); }, $json ); } return $json; } /** * Throws an exception according to a given code with a customized message * * @param int $code return code of json_last_error function * @throws \RuntimeException * @return never */ private static function throwEncodeError(int $code): void { switch ($code) { case JSON_ERROR_DEPTH: $msg = 'Maximum stack depth exceeded'; break; case JSON_ERROR_STATE_MISMATCH: $msg = 'Underflow or the modes mismatch'; break; case JSON_ERROR_CTRL_CHAR: $msg = 'Unexpected control character found'; break; case JSON_ERROR_UTF8: $msg = 'Malformed UTF-8 characters, possibly incorrectly encoded'; break; default: $msg = 'Unknown error'; } throw new \RuntimeException('JSON encoding failed: '.$msg); } /** * Parses json string and returns hash. * * @param null|string $json json string * @param string $file the json file * * @throws ParsingException * @return mixed */ public static function parseJson(?string $json, ?string $file = null) { if (null === $json) { return null; } $data = json_decode($json, true); if (null === $data && JSON_ERROR_NONE !== json_last_error()) { self::validateSyntax($json, $file); } return $data; } /** * Validates the syntax of a JSON string * * @param string $file * @throws \UnexpectedValueException * @throws ParsingException * @return bool true on success */ protected static function validateSyntax(string $json, ?string $file = null): bool { $parser = new JsonParser(); $result = $parser->lint($json); if (null === $result) { if (defined('JSON_ERROR_UTF8') && JSON_ERROR_UTF8 === json_last_error()) { if ($file === null) { throw new \UnexpectedValueException('The input is not UTF-8, could not parse as JSON'); } else { throw new \UnexpectedValueException('"' . $file . '" is not UTF-8, could not parse as JSON'); } } return true; } if ($file === null) { throw new ParsingException('The input does not contain valid JSON' . "\n" . $result->getMessage(), $result->getDetails()); } else { throw new ParsingException('"' . $file . '" does not contain valid JSON' . "\n" . $result->getMessage(), $result->getDetails()); } } public static function detectIndenting(?string $json): string { if (Preg::isMatchStrictGroups('#^([ \t]+)"#m', $json ?? '', $match)) { return $match[1]; } return self::INDENT_DEFAULT; } }