PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
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 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / class-multipart-processor.php

class-multipart-processor.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at vendor/wp-php-toolkit/reprint-server/src/class-multipart-processor.php

751 lines 30.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
751