wikimedia/mediawiki-core

View on GitHub
includes/libs/ParamValidator/Util/UploadedFileStream.php

Summary

Maintainability
A
25 mins
Test Coverage
<?php

namespace Wikimedia\ParamValidator\Util;

use Psr\Http\Message\StreamInterface;
use RuntimeException;
use Stringable;
use Throwable;
use Wikimedia\AtEase\AtEase;

/**
 * Implementation of StreamInterface for a file in $_FILES
 *
 * This exists so ParamValidator needn't depend on any specific PSR-7
 * implementation for a class implementing UploadedFileInterface. It shouldn't
 * be used directly by other code.
 *
 * @internal
 * @since 1.34
 */
class UploadedFileStream implements Stringable, StreamInterface {

    /** @var resource|null File handle */
    private $fp;

    /** @var int|false|null File size. False if not set yet. */
    private $size = false;

    /**
     * Call, throwing on error
     * @param callable $func Callable to call
     * @param array $args Arguments
     * @param mixed $fail Failure return value
     * @param string $msg Message prefix
     * @return mixed
     * @throws RuntimeException if $func returns $fail
     */
    private static function quietCall( callable $func, array $args, $fail, $msg ) {
        error_clear_last();
        $ret = AtEase::quietCall( $func, ...$args );
        if ( $ret === $fail ) {
            $err = error_get_last();
            throw new RuntimeException( "$msg: " . ( $err['message'] ?? 'Unknown error' ) );
        }
        return $ret;
    }

    /**
     * @param string $filename
     */
    public function __construct( $filename ) {
        $this->fp = self::quietCall( 'fopen', [ $filename, 'r' ], false, 'Failed to open file' );
    }

    /**
     * Check if the stream is open
     * @throws RuntimeException if closed
     */
    private function checkOpen() {
        if ( !$this->fp ) {
            throw new RuntimeException( 'Stream is not open' );
        }
    }

    public function __destruct() {
        $this->close();
    }

    public function __toString() {
        try {
            $this->seek( 0 );
            return $this->getContents();
        } catch ( Throwable $ex ) {
            // Not allowed to throw
            return '';
        }
    }

    public function close() {
        if ( $this->fp ) {
            // Spec doesn't care about close errors.
            try {
                // PHP 7 emits warnings, suppress
                AtEase::quietCall( 'fclose', $this->fp );
            } catch ( \TypeError $unused ) {
                // While PHP 8 throws exceptions, ignore
            }
            $this->fp = null;
        }
    }

    public function detach() {
        $ret = $this->fp;
        $this->fp = null;
        return $ret;
    }

    public function getSize() {
        if ( $this->size === false ) {
            $this->size = null;

            if ( $this->fp ) {
                // Spec doesn't care about errors here.
                try {
                    $stat = AtEase::quietCall( 'fstat', $this->fp );
                } catch ( \TypeError $unused ) {
                }
                $this->size = $stat['size'] ?? null;
            }
        }

        return $this->size;
    }

    public function tell() {
        $this->checkOpen();
        return self::quietCall( 'ftell', [ $this->fp ], -1, 'Cannot determine stream position' );
    }

    public function eof() {
        // Spec doesn't care about errors here.
        try {
            return !$this->fp || AtEase::quietCall( 'feof', $this->fp );
        } catch ( \TypeError $unused ) {
            return true;
        }
    }

    public function isSeekable() {
        return (bool)$this->fp;
    }

    public function seek( $offset, $whence = SEEK_SET ) {
        $this->checkOpen();
        self::quietCall( 'fseek', [ $this->fp, $offset, $whence ], -1, 'Seek failed' );
    }

    public function rewind() {
        $this->seek( 0 );
    }

    public function isWritable() {
        return false;
    }

    public function write( $string ) {
        // @phan-suppress-previous-line PhanPluginNeverReturnMethod
        $this->checkOpen();
        throw new RuntimeException( 'Stream is read-only' );
    }

    public function isReadable() {
        return (bool)$this->fp;
    }

    public function read( $length ) {
        $this->checkOpen();
        return self::quietCall( 'fread', [ $this->fp, $length ], false, 'Read failed' );
    }

    public function getContents() {
        $this->checkOpen();
        return self::quietCall( 'stream_get_contents', [ $this->fp ], false, 'Read failed' );
    }

    public function getMetadata( $key = null ) {
        $this->checkOpen();
        $ret = self::quietCall( 'stream_get_meta_data', [ $this->fp ], false, 'Metadata fetch failed' );
        if ( $key !== null ) {
            $ret = $ret[$key] ?? null;
        }
        return $ret;
    }

}