<?php
/**
 * Signed rule loading.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Detection\Rules;

use InvalidArgumentException;
use LocalGuardScanner\Detection\Plugin\PluginAdvisory;
use RuntimeException;

/**
 * Loads only a well-formed and correctly signed local manifest.
 */
final class RuleLoader {
	/** @var RuleVerifier */
	private $verifier;

	/** @param RuleVerifier $verifier Signature verifier. */
	public function __construct( RuleVerifier $verifier ) {
		$this->verifier = $verifier;
	}

	/**
	 * @param string $manifest_path Manifest path.
	 * @param string $signature_path Signature path.
	 * @param string $public_key_path Public key path.
	 * @return RuleSet
	 */
	public function load( $manifest_path, $signature_path, $public_key_path ) {
		$manifest   = $this->read( $manifest_path );
		$signature  = $this->read( $signature_path );
		$public_key = $this->read( $public_key_path );
		if ( ! $this->verifier->verify( $manifest, $signature, $public_key ) ) {
			throw new RuntimeException( 'Rule manifest signature is invalid.' );
		}
		$data = json_decode( $manifest, true );
		if ( ! is_array( $data ) || ! isset( $data['version'], $data['rules'] ) || ! is_string( $data['version'] ) || ! is_array( $data['rules'] ) ) {
			throw new RuntimeException( 'Rule manifest is malformed.' );
		}
		$rules         = array();
		$ids           = array();
		$core_catalog  = array();
		$advisories    = array();
		$advisory_ids  = array();
		$advisory_date = '';
		if ( isset( $data['core_catalog'] ) ) {
			if ( ! is_array( $data['core_catalog'] ) ) {
				throw new RuntimeException( 'Signed core catalog is malformed.' );
			}
			foreach ( $data['core_catalog'] as $path => $hash ) {
				if ( ! is_string( $path ) || ! preg_match( '#^core/[a-z0-9.\-]+\.json$#', $path ) || ! is_string( $hash ) || ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
					throw new RuntimeException( 'Signed core catalog entry is malformed.' );
				}
				$core_catalog[ $path ] = $hash;
			}
		}
		if ( isset( $data['advisories'] ) ) {
			if ( ! isset( $data['advisory_date'] ) || ! is_string( $data['advisory_date'] ) || ! preg_match( '/^20[0-9]{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/', $data['advisory_date'] ) || ! is_array( $data['advisories'] ) ) {
				throw new RuntimeException( 'Signed plugin advisory catalog is malformed.' );
			}
			$advisory_date = $data['advisory_date'];
			foreach ( $data['advisories'] as $advisory_data ) {
				if ( ! is_array( $advisory_data ) ) {
					throw new RuntimeException( 'Plugin advisory entry is malformed.' );
				}
				try {
					$advisory = new PluginAdvisory( $advisory_data );
				} catch ( InvalidArgumentException $error ) {
					throw new RuntimeException( 'Plugin advisory entry is malformed.' );
				}
				if ( isset( $advisory_ids[ $advisory->id() ] ) ) {
					throw new RuntimeException( 'Plugin advisory IDs must be unique.' );
				}
				$advisory_ids[ $advisory->id() ] = true;
				$advisories[]                    = $advisory;
			}
		}
		foreach ( $data['rules'] as $rule_data ) {
			if ( ! is_array( $rule_data ) ) {
				throw new RuntimeException( 'Rule entry is malformed.' );
			}
			$rule = new Rule( $rule_data );
			if ( isset( $ids[ $rule->id() ] ) ) {
				throw new RuntimeException( 'Rule IDs must be unique.' );
			}
			$ids[ $rule->id() ] = true;
			$rules[]            = $rule;
		}
		return new RuleSet( $data['version'], $rules, $core_catalog, $advisories, $advisory_date );
	}

	/**
	 * @param string $path File path.
	 * @return string
	 */
	private function read( $path ) {
		if ( ! is_readable( $path ) || filesize( $path ) > 1024 * 1024 ) {
			throw new RuntimeException( 'Rule file is missing or too large.' );
		}
		$content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		if ( false === $content ) {
			throw new RuntimeException( 'Rule file cannot be read.' );
		}
		return $content;
	}
}
