PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | vendor/wp-php-toolkit/reprint-server/src/class-multipart-processor.php +750 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,750 @@
1 +<?php
2 +
3 +// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Protocol errors become CLI or authenticated API values, never HTML output.
4 +
5 +/**
6 + * Incrementally processes the strict multipart/mixed format used by Reprint.
7 + *
8 + * The processor is independent of the transport which supplies its bytes. A
9 + * cURL response callback can append each received fragment, while an HTTP
10 + * endpoint can append bounded reads from php://input. In both cases the caller
11 + * drains tokens synchronously before appending another fragment:
12 + *
13 + * $multipart->append_bytes($bytes);
14 + * while ($multipart->next_token()) {
15 + * if ($multipart->get_token_type() === Site_Export_Multipart_Processor::TOKEN_BODY) {
16 + * fwrite($output, $multipart->get_current_body_piece());
17 + * }
18 + * }
19 + *
20 + * A part produces one PART_START token, zero or more BODY tokens, and one
21 + * PART_END token. PART_START exposes the complete normalized header map. BODY
22 + * exposes at most MAX_INPUT_FRAGMENT_BYTES and remains current only until
23 + * next_token() is called again. PART_END means every byte declared by
24 + * Content-Length was supplied; the following call validates the CRLF and next
25 + * boundary before it can expose another part.
26 + *
27 + * Reprint deliberately uses a narrower grammar than general Internet MIME.
28 + * Every part requires a decimal Content-Length, all syntax uses CRLF, header
29 + * names are unique, and the request or response begins with its first boundary
30 + * and ends with its closing boundary. Reprint has emitted this form since its
31 + * first multipart exporter. Requiring a length makes arbitrary file bytes
32 + * unambiguous: the processor never searches a binary body for a delimiter and
33 + * can distinguish truncation from a clean close.
34 + *
35 + * next_token() returns false both when more bytes are required and after the
36 + * closing boundary. paused_at_incomplete_input() and is_complete() distinguish
37 + * those states. Once the transport reaches EOF, finish_input() verifies that a
38 + * complete closing boundary was seen; incomplete syntax or body data throws.
39 + * The processor retains only one bounded input fragment, one bounded header
40 + * block, and one current body token. It never accumulates a complete part.
41 + */
42 +final class Site_Export_Multipart_Processor {
43 +
44 + /** Token which exposes the normalized headers of a newly opened part. */
45 + public const TOKEN_PART_START = 'part-start';
46 +
47 + /** Token which exposes one bounded piece of the current part body. */
48 + public const TOKEN_BODY = 'body';
49 +
50 + /** Token which confirms that the current part's declared body is complete. */
51 + public const TOKEN_PART_END = 'part-end';
52 +
53 + /**
54 + * Maximum bytes accepted by one append_bytes() call or exposed as one body token.
55 + *
56 + * Pull drains every cURL fragment immediately and push reads php://input
57 + * using this ceiling. The shared limit prevents either caller from turning
58 + * a streamed part into an unbounded in-memory string.
59 + */
60 + public const MAX_INPUT_FRAGMENT_BYTES = 262144;
61 +
62 + /**
63 + * Maximum bytes accepted in a boundary parameter.
64 + *
65 + * RFC 2046 recommends boundaries no longer than 70 characters. This also
66 + * bounds the delimiter retained while syntax is split across input reads.
67 + */
68 + private const MAX_BOUNDARY_BYTES = 70;
69 +
70 + /**
71 + * Maximum bytes accepted in one boundary or physical header line.
72 + *
73 + * The count excludes CRLF. An unterminated line is rejected as soon as it
74 + * can no longer fit within this bound.
75 + */
76 + public const MAX_HEADER_LINE_BYTES = 8192;
77 +
78 + /**
79 + * Maximum aggregate bytes accepted for one part's headers, including CRLF.
80 + *
81 + * This bounds both ordinary header fields and folded continuation lines
82 + * before the processor exposes any body bytes.
83 + */
84 + private const MAX_HEADER_BYTES = 32768;
85 +
86 + /**
87 + * Maximum number of distinct headers accepted on one part.
88 + *
89 + * Duplicate names are rejected, so this is also the maximum number of
90 + * entries retained in $current_headers.
91 + */
92 + private const MAX_HEADERS = 32;
93 +
94 + /** Processor state which expects an opening or closing delimiter line. */
95 + private const STATE_BOUNDARY = 0;
96 +
97 + /** Processor state which accumulates the current part's bounded header block. */
98 + private const STATE_HEADERS = 1;
99 +
100 + /** Processor state which emits exactly the current part's declared body bytes. */
101 + private const STATE_BODY = 2;
102 +
103 + /** Processor state entered after a syntactically complete closing boundary. */
104 + private const STATE_COMPLETE = 3;
105 +
106 + /**
107 + * Complete delimiter token derived from the validated boundary parameter.
108 + *
109 + * The stored value includes the required leading `--` and is compared only
110 + * with CRLF-terminated syntax lines. Part bodies are framed by their
111 + * Content-Length and are never searched for this byte sequence.
112 + *
113 + * @var string
114 + */
115 + private $delimiter;
116 +
117 + /**
118 + * Bytes not yet consumed by the current processor state.
119 + *
120 + * The caller may append only after next_token() pauses, so this contains at
121 + * most one bounded fragment plus a small split syntax tail.
122 + *
123 + * @var string
124 + */
125 + private $buffer = '';
126 +
127 + /**
128 + * Grammar state describing which multipart construct must be consumed next.
129 + *
130 + * It advances from a boundary to headers to the declared body, then returns
131 + * to a boundary until the closing delimiter makes the processor complete.
132 + *
133 + * @var int One of the STATE_* constants.
134 + */
135 + private $state = self::STATE_BOUNDARY;
136 +
137 + /**
138 + * Whether the next delimiter must be preceded by the completed body's CRLF.
139 + *
140 + * The opening boundary starts at byte zero; every later boundary follows
141 + * exactly the number of body bytes declared by Content-Length and a CRLF.
142 + *
143 + * @var bool
144 + */
145 + private $requires_part_terminator = false;
146 +
147 + /**
148 + * Whether the transport has declared that no more bytes can arrive.
149 + *
150 + * This becomes true only through finish_input(), after the caller has
151 + * drained every token available from the final input fragment.
152 + *
153 + * @var bool
154 + */
155 + private $input_finished = false;
156 +
157 + /**
158 + * Whether next_token() needs another input fragment to make progress.
159 + *
160 + * append_bytes() is permitted only in this state, which prevents callers
161 + * from accumulating several unread transport fragments in $buffer.
162 + *
163 + * @var bool
164 + */
165 + private $paused_at_incomplete_input = true;
166 +
167 + /**
168 + * Token exposed by the most recent successful next_token() call.
169 + *
170 + * Null means the processor is between tokens, paused for another fragment,
171 + * or complete. Token and value getters reject access in that state so a
172 + * caller cannot accidentally reuse information from an earlier part.
173 + *
174 + * @var string|null One of the TOKEN_* constants when a token is current.
175 + */
176 + private $current_token_type = null;
177 +
178 + /**
179 + * Lowercase, unique headers belonging to the current part.
180 + *
181 + * The map is available on PART_START, BODY, and PART_END tokens. It is
182 + * replaced only after the next opening boundary has been validated.
183 + *
184 + * @var array<string,string>
185 + */
186 + private $current_headers = [];
187 +
188 + /**
189 + * Body bytes exposed by the current TOKEN_BODY token.
190 + *
191 + * This contains at most one bounded input fragment and is cleared before
192 + * the processor advances. Callers therefore consume or hand off each piece
193 + * without the processor retaining the complete part body.
194 + *
195 + * @var string
196 + */
197 + private $current_body_piece = '';
198 +
199 + /**
200 + * Aggregate physical header bytes consumed for the current part.
201 + *
202 + * The count includes each line's CRLF, including folded continuations and
203 + * the empty line ending the block. It enforces a whole-header ceiling in
204 + * addition to the limit on any single physical line.
205 + *
206 + * @var int
207 + */
208 + private $current_header_bytes = 0;
209 +
210 + /**
211 + * Normalized name of the header field currently being unfolded.
212 + *
213 + * A field stays pending until another header or the empty terminator line
214 + * arrives, because intervening continuation lines belong to the same
215 + * logical value. Null means no field has begun for the current part.
216 + *
217 + * @var string|null
218 + */
219 + private $pending_header_name = null;
220 +
221 + /**
222 + * Physical value bytes accumulated for $pending_header_name.
223 + *
224 + * Leading whitespace after the colon is removed only when the completed
225 + * field is stored. Continuation-line whitespace remains intact while their
226 + * separating CRLF bytes are omitted according to MIME unfolding.
227 + *
228 + * @var string
229 + */
230 + private $pending_header_value = '';
231 +
232 + /**
233 + * Number of declared body bytes not yet exposed through TOKEN_BODY.
234 + *
235 + * It is initialized from the validated decimal Content-Length and reduced
236 + * by exactly each emitted piece. Reaching zero emits TOKEN_PART_END before
237 + * the following CRLF and boundary are parsed.
238 + *
239 + * @var int
240 + */
241 + private $remaining_body_bytes = 0;
242 +
243 + /**
244 + * Creates a processor positioned before the opening multipart boundary.
245 + *
246 + * @param string $boundary MIME boundary token without the leading `--`.
247 + *
248 + * @throws InvalidArgumentException If the boundary is empty, overlong, or
249 + * contains bytes which cannot appear safely in a delimiter line.
250 + */
251 + public function __construct(string $boundary) {
252 + self::validate_boundary($boundary);
253 + $this->delimiter = '--' . $boundary;
254 + }
255 +
256 + /**
257 + * Returns the validated boundary from a multipart/mixed Content-Type value.
258 + *
259 + * Media types and parameter names are matched case-insensitively. Both the
260 + * token and quoted forms emitted by HTTP implementations are accepted:
261 + *
262 + * multipart/mixed; boundary=reprint-0123
263 + * Multipart/Mixed; boundary="reprint-0123"
264 + *
265 + * The returned value excludes the delimiter's leading `--`. A missing,
266 + * empty, repeated, overlong, or unsafe boundary is rejected before any body
267 + * bytes are accepted.
268 + *
269 + * @param string $content_type Complete Content-Type header value.
270 + * @return string Validated MIME boundary token.
271 + *
272 + * @throws InvalidArgumentException If the media type or boundary is invalid.
273 + */
274 + public static function boundary_from_content_type(string $content_type): string {
275 + $segments = explode(';', $content_type);
276 + $media_type = strtolower(trim( (string) array_shift($segments)));
277 + if ($media_type !== 'multipart/mixed') {
278 + throw new InvalidArgumentException(
279 + 'Expected Content-Type multipart/mixed; received ' . self::describe_bytes($content_type) . '.'
280 + );
281 + }
282 +
283 + $boundary = null;
284 + foreach ($segments as $segment) {
285 + $equals = strpos($segment, '=');
286 + if ($equals === false || strtolower(trim(substr($segment, 0, $equals))) !== 'boundary') {
287 + continue;
288 + }
289 + if ($boundary !== null) {
290 + throw new InvalidArgumentException('Multipart Content-Type contains more than one boundary parameter.');
291 + }
292 + $value = trim(substr($segment, $equals + 1));
293 + if (strlen($value) >= 2 && $value[0] === '"' && substr($value, -1) === '"') {
294 + $value = substr($value, 1, -1);
295 + }
296 + $boundary = $value;
297 + }
298 +
299 + if (!is_string($boundary) || $boundary === '') {
300 + throw new InvalidArgumentException('Multipart Content-Type requires a non-empty boundary parameter.');
301 + }
302 + self::validate_boundary($boundary);
303 + return $boundary;
304 + }
305 +
306 + /**
307 + * Appends one bounded transport fragment after the processor requests input.
308 + *
309 + * Call next_token() until it returns false before appending the next
310 + * fragment. This obligation keeps unread network or request-body fragments
311 + * from accumulating in memory.
312 + *
313 + * @param string $bytes Next raw multipart bytes from the transport.
314 + *
315 + * @throws LogicException If unread tokens remain, input already ended, or
316 + * the closing boundary was already consumed.
317 + * @throws InvalidArgumentException If the fragment exceeds
318 + * MAX_INPUT_FRAGMENT_BYTES.
319 + */
320 + public function append_bytes(string $bytes): void {
321 + if ($this->input_finished) {
322 + throw new LogicException('Cannot append multipart bytes after finish_input().');
323 + }
324 + if ($this->state === self::STATE_COMPLETE) {
325 + throw new LogicException('Cannot append multipart bytes after the closing boundary.');
326 + }
327 + if (!$this->paused_at_incomplete_input || $this->current_token_type !== null) {
328 + throw new LogicException('Call next_token() until the multipart processor requests more input before appending bytes.');
329 + }
330 + $fragment_bytes = strlen($bytes);
331 + if ($fragment_bytes > self::MAX_INPUT_FRAGMENT_BYTES) {
332 + throw new InvalidArgumentException(
333 + 'Multipart input fragment contains ' . $fragment_bytes . ' bytes; the maximum is '
334 + . self::MAX_INPUT_FRAGMENT_BYTES . ' bytes.'
335 + );
336 + }
337 + $this->buffer .= $bytes;
338 + $this->paused_at_incomplete_input = false;
339 + }
340 +
341 + /**
342 + * Advances to the next part-start, body, or part-end token.
343 + *
344 + * True means the token getters describe one current token. False means
345 + * either append_bytes() must supply another fragment or the closing
346 + * boundary is complete; inspect paused_at_incomplete_input() and
347 + * is_complete() to distinguish those states.
348 + *
349 + * @return bool True when a token is current, false when paused or complete.
350 + *
351 + * @throws InvalidArgumentException If multipart syntax or headers are invalid.
352 + */
353 + public function next_token(): bool {
354 + $this->current_token_type = null;
355 + $this->current_body_piece = '';
356 + $this->paused_at_incomplete_input = false;
357 +
358 + while (true) {
359 + if ($this->state === self::STATE_COMPLETE) {
360 + return false;
361 + }
362 +
363 + if ($this->state === self::STATE_BOUNDARY) {
364 + if (!$this->parse_boundary()) {
365 + $this->paused_at_incomplete_input = true;
366 + return false;
367 + }
368 + continue;
369 + }
370 +
371 + if ($this->state === self::STATE_HEADERS) {
372 + if (!$this->parse_headers()) {
373 + $this->paused_at_incomplete_input = true;
374 + return false;
375 + }
376 + return true;
377 + }
378 +
379 + if ($this->remaining_body_bytes === 0) {
380 + $this->state = self::STATE_BOUNDARY;
381 + $this->requires_part_terminator = true;
382 + $this->current_token_type = self::TOKEN_PART_END;
383 + return true;
384 + }
385 + if ($this->buffer === '') {
386 + $this->paused_at_incomplete_input = true;
387 + return false;
388 + }
389 +
390 + $body_bytes = min(
391 + $this->remaining_body_bytes,
392 + strlen($this->buffer),
393 + self::MAX_INPUT_FRAGMENT_BYTES
394 + );
395 + $this->current_body_piece = substr($this->buffer, 0, $body_bytes);
396 + $this->buffer = (string) substr($this->buffer, $body_bytes);
397 + $this->remaining_body_bytes -= $body_bytes;
398 + $this->current_token_type = self::TOKEN_BODY;
399 + return true;
400 + }
401 + }
402 +
403 + /**
404 + * Verifies that the exhausted transport ended after a closing boundary.
405 + *
406 + * The caller must first drain next_token() until it returns false. A clean
407 + * close makes this method idempotent. EOF in a body, header, delimiter, or
408 + * the CRLF between a body and its boundary is reported as truncation.
409 + *
410 + * @throws LogicException If a current token or unread fragment remains.
411 + * @throws RuntimeException If EOF arrived before the multipart close.
412 + */
413 + public function finish_input(): void {
414 + if ($this->input_finished) {
415 + return;
416 + }
417 + $has_unread_fragment = !$this->paused_at_incomplete_input && $this->state !== self::STATE_COMPLETE;
418 + if ($this->current_token_type !== null || $has_unread_fragment) {
419 + throw new LogicException('Call next_token() until it stops before finishing multipart input.');
420 + }
421 + $this->input_finished = true;
422 + if ($this->state === self::STATE_COMPLETE) {
423 + return;
424 + }
425 + if ($this->state === self::STATE_BODY) {
426 + throw new RuntimeException(
427 + 'The multipart body ended before its declared Content-Length; '
428 + . $this->remaining_body_bytes . ' bytes remain.'
429 + );
430 + }
431 + if ($this->state === self::STATE_HEADERS) {
432 + throw new RuntimeException(
433 + 'The multipart body ended while reading a part header block with '
434 + . strlen($this->buffer) . ' buffered bytes.'
435 + );
436 + }
437 + if ($this->requires_part_terminator) {
438 + throw new RuntimeException(
439 + 'The multipart body ended before the CRLF and boundary following a part body; '
440 + . strlen($this->buffer) . ' separator bytes were available.'
441 + );
442 + }
443 + throw new RuntimeException(
444 + 'The multipart body ended before its closing boundary with '
445 + . strlen($this->buffer) . ' buffered bytes.'
446 + );
447 + }
448 +
449 + /**
450 + * Indicates whether next_token() stopped because it needs another fragment.
451 + *
452 + * @return bool True when append_bytes() may supply more input.
453 + */
454 + public function paused_at_incomplete_input(): bool {
455 + return $this->paused_at_incomplete_input && !$this->input_finished;
456 + }
457 +
458 + /**
459 + * Indicates whether a syntactically complete closing boundary was consumed.
460 + *
461 + * @return bool True after the multipart message has cleanly closed.
462 + */
463 + public function is_complete(): bool {
464 + return $this->state === self::STATE_COMPLETE;
465 + }
466 +
467 + /**
468 + * Returns the type of the token exposed by the latest successful next_token().
469 + *
470 + * @return string One of the TOKEN_* constants.
471 + *
472 + * @throws LogicException If no token is current.
473 + */
474 + public function get_token_type(): string {
475 + if ($this->current_token_type === null) {
476 + throw new LogicException('No multipart token is current; call next_token() first.');
477 + }
478 + return $this->current_token_type;
479 + }
480 +
481 + /**
482 + * Returns the normalized headers belonging to the current part token.
483 + *
484 + * Header names are lowercase and unique. Folded physical lines have their
485 + * CRLF removed; leading whitespace after a colon is discarded while
486 + * continuation whitespace and trailing value whitespace are preserved.
487 + *
488 + * @return array<string,string> Current part headers by lowercase name.
489 + *
490 + * @throws LogicException If no part token is current.
491 + */
492 + public function get_current_headers(): array {
493 + if ($this->current_token_type === null) {
494 + throw new LogicException('No multipart part token is current; call next_token() first.');
495 + }
496 + return $this->current_headers;
497 + }
498 +
499 + /**
500 + * Returns the bytes exposed by the current body token.
501 + *
502 + * The returned string is replaced on the next next_token() call. Callers
503 + * should write or otherwise consume it before advancing.
504 + *
505 + * @return string Non-empty bounded body fragment.
506 + *
507 + * @throws LogicException If the current token is not TOKEN_BODY.
508 + */
509 + public function get_current_body_piece(): string {
510 + if ($this->current_token_type !== self::TOKEN_BODY) {
511 + throw new LogicException('The current multipart token does not contain body bytes.');
512 + }
513 + return $this->current_body_piece;
514 + }
515 +
516 + /**
517 + * Consumes one exact opening or closing delimiter line when available.
518 + *
519 + * @return bool True after a complete delimiter transition, false when its
520 + * bytes are split across the next input fragment.
521 + *
522 + * @throws InvalidArgumentException If the separator or delimiter is invalid.
523 + */
524 + private function parse_boundary(): bool {
525 + if ($this->requires_part_terminator) {
526 + if (strlen($this->buffer) < 2) {
527 + return false;
528 + }
529 + $separator = substr($this->buffer, 0, 2);
530 + if ($separator !== "\r\n") {
531 + throw new InvalidArgumentException(
532 + 'A multipart part body must be followed by CRLF before its boundary; received '
533 + . self::describe_bytes($separator) . '.'
534 + );
535 + }
536 + $this->buffer = (string) substr($this->buffer, 2);
537 + $this->requires_part_terminator = false;
538 + }
539 +
540 + $boundary_line = $this->read_syntax_line('the multipart boundary');
541 + if ($boundary_line === null) {
542 + return false;
543 + }
544 + if ($boundary_line === $this->delimiter . '--') {
545 + if ($this->buffer !== '') {
546 + throw new InvalidArgumentException(
547 + 'Multipart data contains ' . strlen($this->buffer) . ' bytes after the closing boundary.'
548 + );
549 + }
550 + $this->state = self::STATE_COMPLETE;
551 + $this->current_headers = [];
552 + return true;
553 + }
554 + if ($boundary_line !== $this->delimiter) {
555 + throw new InvalidArgumentException(
556 + 'Expected multipart boundary "' . $this->delimiter . '"; received '
557 + . self::describe_bytes($boundary_line) . '.'
558 + );
559 + }
560 +
561 + $this->state = self::STATE_HEADERS;
562 + $this->current_headers = [];
563 + $this->current_header_bytes = 0;
564 + $this->pending_header_name = null;
565 + $this->pending_header_value = '';
566 + return true;
567 + }
568 +
569 + /**
570 + * Consumes header lines until one complete normalized header map is ready.
571 + *
572 + * @return bool True after exposing TOKEN_PART_START, false when the next
573 + * physical header line is split across input fragments.
574 + *
575 + * @throws InvalidArgumentException If a line, field, duplicate, aggregate,
576 + * or required Content-Length violates the Reprint grammar.
577 + */
578 + private function parse_headers(): bool {
579 + while (true) {
580 + $line = $this->read_syntax_line('a multipart part header');
581 + if ($line === null) {
582 + return false;
583 + }
584 + $this->current_header_bytes += strlen($line) + 2;
585 + if ($this->current_header_bytes > self::MAX_HEADER_BYTES) {
586 + throw new InvalidArgumentException(
587 + 'Multipart part headers exceed ' . self::MAX_HEADER_BYTES . ' bytes; received '
588 + . $this->current_header_bytes . ' bytes.'
589 + );
590 + }
591 +
592 + if ($line !== '' && ( $line[0] === ' ' || $line[0] === "\t" )) {
593 + if ($this->pending_header_name === null) {
594 + throw new InvalidArgumentException('Multipart part header continuation has no preceding header field.');
595 + }
596 + // MIME unfolding removes the physical CRLF but preserves the
597 + // continuation's whitespace as part of the logical value.
598 + $this->pending_header_value .= $line;
599 + continue;
600 + }
601 +
602 + $this->store_pending_header();
603 + if ($line === '') {
604 + $content_length = $this->current_headers['content-length'] ?? null;
605 + if (!is_string($content_length) || !preg_match('/^(?:0|[1-9][0-9]*)$/D', $content_length)) {
606 + $received_content_length = is_string($content_length)
607 + ? self::describe_bytes($content_length)
608 + : 'no Content-Length header';
609 + throw new InvalidArgumentException(
610 + 'Every Reprint multipart part requires a non-negative integer Content-Length; received '
611 + . $received_content_length . '.'
612 + );
613 + }
614 + $maximum_integer = (string) PHP_INT_MAX;
615 + if (strlen($content_length) > strlen($maximum_integer)
616 + || ( strlen($content_length) === strlen($maximum_integer)
617 + && strcmp($content_length, $maximum_integer) > 0 )) {
618 + throw new InvalidArgumentException(
619 + 'Multipart part Content-Length exceeds this runtime\'s integer range: ' . $content_length . '.'
620 + );
621 + }
622 + $this->remaining_body_bytes = (int) $content_length;
623 + $this->state = self::STATE_BODY;
624 + $this->current_token_type = self::TOKEN_PART_START;
625 + return true;
626 + }
627 +
628 + $colon = strpos($line, ':');
629 + if ($colon === false || $colon === 0) {
630 + throw new InvalidArgumentException(
631 + 'Malformed multipart part header ' . self::describe_bytes($line) . '.'
632 + );
633 + }
634 + $name = substr($line, 0, $colon);
635 + if (!preg_match('/^[!#$%&\'\*+\-.^_`|~0-9A-Za-z]+$/D', $name)) {
636 + throw new InvalidArgumentException(
637 + 'Multipart part has invalid header name ' . self::describe_bytes($name) . '.'
638 + );
639 + }
640 + $this->pending_header_name = strtolower($name);
641 + $this->pending_header_value = substr($line, $colon + 1);
642 + }
643 + }
644 +
645 + /**
646 + * Moves the pending physical field into the unique normalized map.
647 + *
648 + * A field remains pending while continuation lines arrive. It is stored
649 + * only when the next field or the header-block terminator is complete, so
650 + * continuation lines do not consume the distinct-header count.
651 + *
652 + * @throws InvalidArgumentException If the field repeats a name or exceeds
653 + * the maximum distinct-header count.
654 + */
655 + private function store_pending_header(): void {
656 + if ($this->pending_header_name === null) {
657 + return;
658 + }
659 + $current_header_count = count($this->current_headers);
660 + if ($current_header_count >= self::MAX_HEADERS) {
661 + $received_header_count = $current_header_count + 1;
662 + throw new InvalidArgumentException(
663 + 'Multipart part has more than ' . self::MAX_HEADERS . ' headers; received '
664 + . $received_header_count . '.'
665 + );
666 + }
667 + if (isset($this->current_headers[$this->pending_header_name])) {
668 + throw new InvalidArgumentException(
669 + 'Multipart part repeats header ' . json_encode($this->pending_header_name) . '.'
670 + );
671 + }
672 + $this->current_headers[$this->pending_header_name] = ltrim($this->pending_header_value, " \t");
673 + $this->pending_header_name = null;
674 + $this->pending_header_value = '';
675 + }
676 +
677 + /**
678 + * Removes one required CRLF-terminated syntax line from the input buffer.
679 + *
680 + * @param string $description Human-readable construct named in failures.
681 + * @return string|null Line without CRLF, or null when the line is incomplete.
682 + *
683 + * @throws InvalidArgumentException If LF is bare or the physical line
684 + * exceeds the fixed line limit before its CRLF arrives.
685 + */
686 + private function read_syntax_line(string $description): ?string {
687 + $line_feed = strpos($this->buffer, "\n");
688 + if ($line_feed === false) {
689 + if (strlen($this->buffer) > self::MAX_HEADER_LINE_BYTES + 1) {
690 + throw new InvalidArgumentException(
691 + 'Multipart ' . $description . ' exceeds ' . self::MAX_HEADER_LINE_BYTES
692 + . ' bytes or is missing CRLF; buffered ' . strlen($this->buffer) . ' bytes.'
693 + );
694 + }
695 + return null;
696 + }
697 + if ($line_feed === 0 || $this->buffer[$line_feed - 1] !== "\r") {
698 + throw new InvalidArgumentException('Multipart ' . $description . ' must end with CRLF, not bare LF.');
699 + }
700 + $line_bytes = $line_feed - 1;
701 + if ($line_bytes > self::MAX_HEADER_LINE_BYTES) {
702 + throw new InvalidArgumentException(
703 + 'Multipart ' . $description . ' exceeds ' . self::MAX_HEADER_LINE_BYTES
704 + . ' bytes; received ' . $line_bytes . ' bytes.'
705 + );
706 + }
707 + $line = substr($this->buffer, 0, $line_bytes);
708 + $this->buffer = (string) substr($this->buffer, $line_feed + 1);
709 + return $line;
710 + }
711 +
712 + /**
713 + * Validates a boundary before it is interpolated into delimiter lines.
714 + *
715 + * The accepted punctuation is MIME's bcharsnospace set. Spaces are
716 + * deliberately excluded even in a quoted parameter because Reprint never
717 + * emits them and the narrower set makes line interpretation unambiguous.
718 + *
719 + * @param string $boundary Boundary token without leading `--`.
720 + *
721 + * @throws InvalidArgumentException If the token is empty, overlong, or unsafe.
722 + */
723 + private static function validate_boundary(string $boundary): void {
724 + if ($boundary === '' || strlen($boundary) > self::MAX_BOUNDARY_BYTES) {
725 + throw new InvalidArgumentException(
726 + 'Multipart boundary must contain between 1 and ' . self::MAX_BOUNDARY_BYTES
727 + . ' bytes; received ' . strlen($boundary) . ' bytes.'
728 + );
729 + }
730 + if (!preg_match("/^[0-9A-Za-z'()+_,.\\/:=?-]+$/D", $boundary)) {
731 + throw new InvalidArgumentException(
732 + 'Multipart boundary contains unsupported characters: ' . self::describe_bytes($boundary) . '.'
733 + );
734 + }
735 + }
736 +
737 + /**
738 + * Formats arbitrary protocol bytes without assuming they are valid UTF-8.
739 + *
740 + * JSON keeps ordinary header values readable. Hexadecimal remains lossless
741 + * when malformed input contains bytes which json_encode() cannot represent.
742 + *
743 + * @param string $bytes Raw value observed on the multipart wire.
744 + * @return string Quoted JSON text or a hexadecimal byte string.
745 + */
746 + private static function describe_bytes(string $bytes): string {
747 + $json = json_encode($bytes);
748 + return $json === false ? '0x' . bin2hex($bytes) : $json;
749 + }
750 +}