Lines 62.68% 42 / 67
Methods 44.44% 4 / 9
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 88.88% 16 / 18 0.00% 0 / 1 8.09
 getStream 0.00% 0 / 10 0.00% 0 / 1 30
 moveTo 73.33% 11 / 15 0.00% 0 / 1 9.21
 moveFile 0.00% 0 / 6 0.00% 0 / 1 20
 moveStream 78.57% 11 / 14 0.00% 0 / 1 6.35
 getSize 100.00% 1 / 1 100.00% 1 / 1 1
 getError 100.00% 1 / 1 100.00% 1 / 1 1
 getClientFilename 100.00% 1 / 1 100.00% 1 / 1 1
 getClientMediaType 100.00% 1 / 1 100.00% 1 / 1 1
16final class UploadedFile implements UploadedFileInterface
17{
18    private ?StreamInterface $stream = null;
19    private ?string $file = null;
20    private ?int $size = null;
21    private int $error;
22    private ?string $clientFilename = null;
23    private ?string $clientMediaType = null;
24    private bool $moved = false;
25
26    /** @var int[] Valid UPLOAD_ERR_* constants */
27    private const VALID_ERRORS = [
28        UPLOAD_ERR_OK,
29        UPLOAD_ERR_INI_SIZE,
30        UPLOAD_ERR_FORM_SIZE,
31        UPLOAD_ERR_PARTIAL,
32        UPLOAD_ERR_NO_FILE,
33        UPLOAD_ERR_NO_TMP_DIR,
34        UPLOAD_ERR_CANT_WRITE,
35        UPLOAD_ERR_EXTENSION,
36    ];
37
38    /**
39     * @param StreamInterface|string|resource $streamOrFile Stream, file path, or resource
40     * @param int|null $size File size in bytes
41     * @param int $error Upload error code (UPLOAD_ERR_*)
42     * @param string|null $clientFilename Original client filename
43     * @param string|null $clientMediaType Original client media type
44     * @throws \InvalidArgumentException If the error code is invalid, a stream
45     *     is not readable, or the source type is unsupported
46     */
47    public function __construct(
48        mixed $streamOrFile,
49        ?int $size = null,
50        int $error = UPLOAD_ERR_OK,
51        ?string $clientFilename = null,
52        ?string $clientMediaType = null
53    ) {
54        if (!in_array($error, self::VALID_ERRORS, true)) {
55            throw new \InvalidArgumentException("Invalid upload error code: $error (must be a UPLOAD_ERR_* constant)");
56        }
57
58        if ($streamOrFile instanceof StreamInterface) {
59            if (!$streamOrFile->isReadable()) {
60                throw new \InvalidArgumentException('Uploaded file stream must be readable');
61            }
62            $this->stream = $streamOrFile;
63        } elseif (is_resource($streamOrFile)) {
64            $stream = Stream::fromResource($streamOrFile);
65            if (!$stream->isReadable()) {
66                throw new \InvalidArgumentException('Uploaded file resource must be readable');
67            }
68            $this->stream = $stream;
69        } elseif (is_string($streamOrFile) && $streamOrFile !== '') {
70            $this->file = $streamOrFile;
71        } else {
72            throw new \InvalidArgumentException('Uploaded file source must be a StreamInterface, a non-empty file path string, or a resource');
73        }
74
75        $this->size = $size;
76        $this->error = $error;
77        $this->clientFilename = $clientFilename;
78        $this->clientMediaType = $clientMediaType;
79    }
80
81    public function getStream(): StreamInterface
82    {
83        if ($this->moved) {
84            throw new RuntimeException('Cannot get stream after the file has been moved');
85        }
86
87        if ($this->stream === null) {
88            if ($this->error !== UPLOAD_ERR_OK) {
89                throw new RuntimeException('Cannot retrieve stream due to upload error');
90            }
91
92            $resource = fopen($this->file, 'r');
93            if ($resource === false) {
94                throw new RuntimeException('Unable to open uploaded file');
95            }
96
97            $this->stream = Stream::fromResource($resource);
98        }
99
100        return $this->stream;
101    }
102
103    /**
104     * @throws \InvalidArgumentException if the $targetPath specified is invalid
105     * @throws \RuntimeException on any error during the move, or on subsequent calls
106     */
107    public function moveTo(string $targetPath): void
108    {
109        if ($this->moved) {
110            throw new RuntimeException('File has already been moved');
111        }
112
113        if ($targetPath === '' || str_contains($targetPath, "\0")) {
114            throw new \InvalidArgumentException('Target path must be a non-empty string without null bytes');
115        }
116
117        if ($this->error !== UPLOAD_ERR_OK) {
118            throw new RuntimeException('Cannot move file due to upload error');
119        }
120
121        $dir = dirname($targetPath);
122        if (! is_dir($dir)) {
123            throw new RuntimeException("Target directory does not exist: $dir");
124        }
125
126        if ($this->file !== null) {
127            $this->moveFile($this->file, $targetPath);
128        } elseif ($this->stream !== null) {
129            $this->moveStream($targetPath);
130        } else {
131            throw new RuntimeException('No source file or stream available');
132        }
133
134        $this->moved = true;
135    }
136
137    /**
138     * Move a file-backed upload using the appropriate SAPI-aware mechanism.
139     *
140     * @throws \RuntimeException
141     */
142    private function moveFile(string $source, string $targetPath): void
143    {
144        if (is_uploaded_file($source)) {
145            if (! move_uploaded_file($source, $targetPath)) {
146                throw new RuntimeException("Unable to move uploaded file to $targetPath");
147            }
148        } else {
149            // Not an uploaded file (e.g., testing), use rename
150            if (! rename($source, $targetPath)) {
151                throw new RuntimeException("Unable to rename file to $targetPath");
152            }
153        }
154
155        $this->file = $targetPath;
156    }
157
158    /**
159     * Move a stream-backed upload by writing the stream to the target path.
160     *
161     * @throws \RuntimeException
162     */
163    private function moveStream(string $targetPath): void
164    {
165        $target = fopen($targetPath, 'wb');
166        if ($target === false) {
167            throw new RuntimeException("Unable to open target path for writing: $targetPath");
168        }
169
170        try {
171            $stream = $this->stream;
172            if ($stream->isSeekable()) {
173                $stream->rewind();
174            }
175            while (!$stream->eof()) {
176                $chunk = $stream->read(8192);
177                if ($chunk === '') {
178                    break;
179                }
180                if (fwrite($target, $chunk) === false) {
181                    throw new RuntimeException("Unable to write to target path: $targetPath");
182                }
183            }
184        } finally {
185            fclose($target);
186        }
187
188        // The original stream is consumed by the move — detach it.
189        $this->stream = null;
190    }
191
192    public function getSize(): ?int
193    {
194        return $this->size;
195    }
196
197    public function getError(): int
198    {
199        return $this->error;
200    }
201
202    public function getClientFilename(): ?string
203    {
204        return $this->clientFilename;
205    }
206
207    public function getClientMediaType(): ?string
208    {
209        return $this->clientMediaType;
210    }
211}