<?php
/**
 * Race-resistant read-only file opening.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Detection\Filesystem;

use RuntimeException;

/**
 * Refuses symlinks and verifies that the opened file is the enumerated object.
 */
final class FileIdentityGuard {
	/**
	 * @param string              $path     Enumerated path.
	 * @param array<string,mixed> $expected Expected lstat identity.
	 * @return resource
	 */
	public function open( $path, array $expected = array() ) {
		$before = @lstat( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( false === $before || is_link( $path ) ) {
			throw new RuntimeException( 'File identity changed before it could be opened.' );
		}
		$handle = @fopen( $path, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		if ( false === $handle ) {
			throw new RuntimeException( 'File cannot be opened safely.' );
		}
		$opened = fstat( $handle );
		if ( false === $opened || ! $this->same_identity( $before, $opened ) || ( ! empty( $expected ) && ! $this->same_identity( $expected, $opened ) ) ) {
			fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			throw new RuntimeException( 'File identity changed during opening.' );
		}
		return $handle;
	}

	/**
	 * @param resource            $handle   Open handle.
	 * @param array<string,mixed> $expected Expected lstat identity.
	 * @return void
	 */
	public function assert_stable( $handle, array $expected = array() ) {
		$after = fstat( $handle );
		if ( false === $after || ( ! empty( $expected ) && ! $this->same_identity( $expected, $after ) ) || ( isset( $expected['size'] ) && (int) $expected['size'] !== (int) $after['size'] ) || ( isset( $expected['mtime'] ) && (int) $expected['mtime'] !== (int) $after['mtime'] ) ) {
			throw new RuntimeException( 'File changed while it was being scanned.' );
		}
	}

	/**
	 * @param array<int|string,mixed> $left  First stat.
	 * @param array<int|string,mixed> $right Second stat.
	 * @return bool
	 */
	private function same_identity( array $left, array $right ) {
		return isset( $left['dev'], $left['ino'], $right['dev'], $right['ino'] ) && (int) $left['dev'] === (int) $right['dev'] && (int) $left['ino'] === (int) $right['ino'];
	}
}
