This commit is contained in:
Javier Casares 2026-07-18 13:29:38 +00:00
commit 916d7845be
91 changed files with 6861 additions and 6907 deletions

View file

@ -1,5 +1,40 @@
== Changelog == == Changelog ==
= 1.3.0 =
_Release date: 2026-07-18_
**Highlights**
* New image sub-sizes control: ship less data per upload, ideal for large camera files. Reduces work in both the WP 7.1 client-side path (browser) and the server-side path.
**Added**
* Settings field **Image Sub-sizes** under Settings → iDrivee2 with three modes: `all` (default), `thumbnail` (only the default thumbnail), `none` (no sub-sizes).
* `IDRIVEE2_MEDIA_SUBSIZES_MODE` wp-config.php constant — same priority pattern as the S3 credentials; the field becomes read-only when set.
* In `thumbnail` and `none` modes the `big_image_size_threshold` filter is also disabled, so WordPress no longer creates a `-scaled` derivative for images larger than 2560px.
**Security**
* `composer audit` CVEs resolved: `guzzlehttp/guzzle` 7.11 → 7.15, `guzzlehttp/psr7` 2.11 → 2.13, `mtdowling/jmespath.php` 2.8 → 2.9 (CVE-2026-55767 / 55568 / 55766 / 54133).
**Tooling**
* `bin/preflight.sh` added — automated pre-deploy verification per AGENTS-testing-build-deployment.md (PHPCS, PHPStan, PHPCompatibility, PHPUnit + coverage, composer audit, candidate ZIP inspection).
* `.claude/settings.json` added — mechanical deny rules for `deploy.sh`, `git push/tag/merge` per AGENTS.md.
**Compatibility**
* WordPress: 4.1 - 7.1
* PHP: 8.1 - 8.5
**Tests**
* PHP Coding Standards: 3.13.5 (0 errors)
* WordPress Coding Standards: 3.3.0 (0 violations)
* PHPStan: Level 9, 0 errors
* PHPUnit: 38 tests, 60 assertions
= 1.2.1 = = 1.2.1 =
_Release date: 2026-06-05_ _Release date: 2026-06-05_

View file

@ -5,7 +5,7 @@
* Gitea Plugin URI: https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload * Gitea Plugin URI: https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload
* Primary Branch: main * Primary Branch: main
* Description: Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging. * Description: Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging.
* Version: 1.2.1 * Version: 1.3.0
* Requires at least: 4.1 * Requires at least: 4.1
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: ROBOTSTXT * Author: ROBOTSTXT
@ -36,7 +36,7 @@ if ( ! defined( 'ABSPATH' ) ) {
* *
* @since 1.1.4 * @since 1.1.4
*/ */
define( 'IDRIVEE2_MEDIA_VERSION', '1.2.1' ); define( 'IDRIVEE2_MEDIA_VERSION', '1.3.0' );
/** /**
* Load Composer autoloader if available. * Load Composer autoloader if available.
@ -60,6 +60,7 @@ require_once __DIR__ . '/includes/class-s3-client-factory.php';
require_once __DIR__ . '/includes/class-url-rewriter.php'; require_once __DIR__ . '/includes/class-url-rewriter.php';
require_once __DIR__ . '/includes/class-media-uploader.php'; require_once __DIR__ . '/includes/class-media-uploader.php';
require_once __DIR__ . '/includes/class-admin-page.php'; require_once __DIR__ . '/includes/class-admin-page.php';
require_once __DIR__ . '/includes/class-subsize-filter.php';
require_once __DIR__ . '/includes/class-plugin.php'; require_once __DIR__ . '/includes/class-plugin.php';
/** /**

View file

@ -526,6 +526,11 @@ class Admin_Page {
$sanitized['region'] = isset( $input['region'] ) ? sanitize_text_field( $input['region'] ) : ''; $sanitized['region'] = isset( $input['region'] ) ? sanitize_text_field( $input['region'] ) : '';
$sanitized['domain'] = isset( $input['domain'] ) ? sanitize_text_field( $input['domain'] ) : ''; $sanitized['domain'] = isset( $input['domain'] ) ? sanitize_text_field( $input['domain'] ) : '';
// Sub-sizes mode: strict whitelist, default 'all'.
$raw_mode = isset( $input['subsizes_mode'] ) ? strtolower( (string) sanitize_text_field( $input['subsizes_mode'] ) ) : '';
$allowed_modes = array( 'all', 'thumbnail', 'none' );
$sanitized['subsizes_mode'] = in_array( $raw_mode, $allowed_modes, true ) ? $raw_mode : 'all';
return $sanitized; return $sanitized;
} }
@ -571,6 +576,7 @@ class Admin_Page {
$bucket = $this->config->get_bucket(); $bucket = $this->config->get_bucket();
$region = $this->config->get_region(); $region = $this->config->get_region();
$domain = $this->config->get_domain(); $domain = $this->config->get_domain();
$subsizes_mode = $this->config->get_subsizes_mode();
$is_configured = $this->config->is_configured(); $is_configured = $this->config->is_configured();
@ -581,8 +587,9 @@ class Admin_Page {
$bucket_in_config = $this->config->is_defined_in_wp_config( 'bucket' ); $bucket_in_config = $this->config->is_defined_in_wp_config( 'bucket' );
$region_in_config = $this->config->is_defined_in_wp_config( 'region' ); $region_in_config = $this->config->is_defined_in_wp_config( 'region' );
$domain_in_config = $this->config->is_defined_in_wp_config( 'domain' ); $domain_in_config = $this->config->is_defined_in_wp_config( 'domain' );
$subsizes_mode_in_config = $this->config->is_defined_in_wp_config( 'subsizes_mode' );
$any_in_config = $host_in_config || $key_in_config || $secret_in_config || $bucket_in_config || $region_in_config || $domain_in_config; $any_in_config = $host_in_config || $key_in_config || $secret_in_config || $bucket_in_config || $region_in_config || $domain_in_config || $subsizes_mode_in_config;
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e( 'iDrivee2 Media Upload Settings', 'idrivee2-media-upload' ); ?></h1> <h1><?php esc_html_e( 'iDrivee2 Media Upload Settings', 'idrivee2-media-upload' ); ?></h1>
@ -756,6 +763,54 @@ class Admin_Page {
</p> </p>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">
<label for="idrivee2_subsizes_mode"><?php esc_html_e( 'Image Sub-sizes', 'idrivee2-media-upload' ); ?></label>
</th>
<td>
<select
id="idrivee2_subsizes_mode"
name="idrivee2_media_settings[subsizes_mode]"
<?php echo $subsizes_mode_in_config ? 'disabled' : ''; ?>
>
<?php
$mode_options = array(
'all' => __( 'All registered sizes (WordPress default)', 'idrivee2-media-upload' ),
'thumbnail' => __( 'Only thumbnail (faster uploads)', 'idrivee2-media-upload' ),
'none' => __( 'No sub-sizes (original only)', 'idrivee2-media-upload' ),
);
foreach ( $mode_options as $value => $label ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $value ),
selected( $subsizes_mode, $value, false ),
esc_html( $label )
);
}
?>
</select>
<?php
// Disabled selects are not submitted with the form; preserve the
// constant-defined value via a hidden input so it is not lost on save.
if ( $subsizes_mode_in_config ) {
printf(
'<input type="hidden" name="idrivee2_media_settings[subsizes_mode]" value="%s" />',
esc_attr( $subsizes_mode )
);
}
?>
<p class="description">
<?php
if ( $subsizes_mode_in_config ) {
esc_html_e( 'Defined in wp-config.php (IDRIVEE2_MEDIA_SUBSIZES_MODE)', 'idrivee2-media-upload' );
} else {
esc_html_e( 'Reduces work for large uploads (e.g. camera files). Also disables the -scaled derivative in thumbnail/none modes.', 'idrivee2-media-upload' );
}
?>
</p>
</td>
</tr>
</tbody> </tbody>
</table> </table>
@ -767,7 +822,7 @@ class Admin_Page {
</p> </p>
<?php <?php
// Show submit button only if at least one field can be edited. // Show submit button only if at least one field can be edited.
$can_edit = ! $host_in_config || ! $key_in_config || ! $secret_in_config || ! $bucket_in_config || ! $region_in_config || ! $domain_in_config; $can_edit = ! $host_in_config || ! $key_in_config || ! $secret_in_config || ! $bucket_in_config || ! $region_in_config || ! $domain_in_config || ! $subsizes_mode_in_config;
if ( $can_edit ) : if ( $can_edit ) :
?> ?>
<?php submit_button( __( 'Save Settings', 'idrivee2-media-upload' ) ); ?> <?php submit_button( __( 'Save Settings', 'idrivee2-media-upload' ) ); ?>

View file

@ -16,7 +16,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
/** /**
* Configuration validator and accessor for iDrivee2 constants. * Configuration validator and accessor for iDrivee2 constants.
* *
* Validates and provides access to the five required configuration constants * Validates and provides access to the five required configuration constants
@ -46,6 +46,25 @@ class Config {
*/ */
private const OPTION_NAME = 'idrivee2_media_settings'; private const OPTION_NAME = 'idrivee2_media_settings';
/**
* Map of config keys to wp-config.php constant names.
*
* Centralised so is_defined_in_wp_config() and get_value() cannot drift.
*
* @since 1.3.0
*
* @var array<string, string>
*/
private const CONSTANT_MAP = array(
'host' => 'IDRIVEE2_MEDIA_HOST',
'key' => 'IDRIVEE2_MEDIA_KEY',
'secret' => 'IDRIVEE2_MEDIA_SECRET',
'bucket' => 'IDRIVEE2_MEDIA_BUCKET',
'region' => 'IDRIVEE2_MEDIA_REGION',
'domain' => 'IDRIVEE2_MEDIA_DOMAIN',
'subsizes_mode' => 'IDRIVEE2_MEDIA_SUBSIZES_MODE',
);
/** /**
* Logger instance. * Logger instance.
* *
@ -92,17 +111,8 @@ class Config {
* @return bool True if defined in wp-config.php, false otherwise. * @return bool True if defined in wp-config.php, false otherwise.
*/ */
public function is_defined_in_wp_config( string $key ): bool { public function is_defined_in_wp_config( string $key ): bool {
$constant_map = array( $constant_name = self::CONSTANT_MAP[ $key ] ?? '';
'host' => 'IDRIVEE2_MEDIA_HOST', return '' !== $constant_name && defined( $constant_name );
'key' => 'IDRIVEE2_MEDIA_KEY',
'secret' => 'IDRIVEE2_MEDIA_SECRET',
'bucket' => 'IDRIVEE2_MEDIA_BUCKET',
'region' => 'IDRIVEE2_MEDIA_REGION',
'domain' => 'IDRIVEE2_MEDIA_DOMAIN',
);
$constant_name = $constant_map[ $key ] ?? '';
return $constant_name && defined( $constant_name );
} }
/** /**
@ -116,18 +126,9 @@ class Config {
* @return string The configuration value, or empty string if not set. * @return string The configuration value, or empty string if not set.
*/ */
private function get_value( string $key ): string { private function get_value( string $key ): string {
$constant_map = array(
'host' => 'IDRIVEE2_MEDIA_HOST',
'key' => 'IDRIVEE2_MEDIA_KEY',
'secret' => 'IDRIVEE2_MEDIA_SECRET',
'bucket' => 'IDRIVEE2_MEDIA_BUCKET',
'region' => 'IDRIVEE2_MEDIA_REGION',
'domain' => 'IDRIVEE2_MEDIA_DOMAIN',
);
// Check wp-config.php constant first (highest priority). // Check wp-config.php constant first (highest priority).
$constant_name = $constant_map[ $key ] ?? ''; $constant_name = self::CONSTANT_MAP[ $key ] ?? '';
if ( $constant_name && defined( $constant_name ) ) { if ( '' !== $constant_name && defined( $constant_name ) ) {
return (string) constant( $constant_name ); return (string) constant( $constant_name );
} }
@ -216,6 +217,29 @@ class Config {
return ! empty( $this->get_domain() ); return ! empty( $this->get_domain() );
} }
/**
* Get the configured sub-sizes mode.
*
* Controls which image sub-sizes WordPress generates. Returns one of
* 'all', 'thumbnail', or 'none'; falls back to 'all' for any value that
* is not on the whitelist (including unset/empty).
*
* @since 1.3.0
*
* @return string The sub-sizes mode.
*/
public function get_subsizes_mode(): string {
$value = $this->get_value( 'subsizes_mode' );
$allowed = array(
'all',
'thumbnail',
'none',
);
return in_array( $value, $allowed, true ) ? $value : 'all';
}
/** /**
* Update configuration options in WordPress database. * Update configuration options in WordPress database.
* *
@ -239,6 +263,11 @@ class Config {
'domain' => isset( $data['domain'] ) ? sanitize_text_field( $data['domain'] ) : '', 'domain' => isset( $data['domain'] ) ? sanitize_text_field( $data['domain'] ) : '',
); );
// Sub-sizes mode: strict whitelist, default 'all'.
$raw_mode = isset( $data['subsizes_mode'] ) ? strtolower( (string) sanitize_text_field( $data['subsizes_mode'] ) ) : '';
$allowed_modes = array( 'all', 'thumbnail', 'none' );
$options['subsizes_mode'] = in_array( $raw_mode, $allowed_modes, true ) ? $raw_mode : 'all';
// Log configuration changes. // Log configuration changes.
if ( $this->logger ) { if ( $this->logger ) {
foreach ( $options as $key => $new_value ) { foreach ( $options as $key => $new_value ) {

View file

@ -29,7 +29,7 @@ class Media_Uploader {
/** /**
* Attachment IDs currently being uploaded, to prevent re-entrant calls. * Attachment IDs currently being uploaded, to prevent re-entrant calls.
* *
* wp_update_post() (used to update the attachment GUID) fires edit_attachment, * Calling wp_update_post() to update the attachment GUID fires edit_attachment,
* which would re-trigger upload_attachment_to_idrivee2() causing infinite recursion. * which would re-trigger upload_attachment_to_idrivee2() causing infinite recursion.
* *
* @var array<int, bool> * @var array<int, bool>
@ -145,7 +145,7 @@ class Media_Uploader {
$base_path = path_join( $basedir, $meta_file ); $base_path = path_join( $basedir, $meta_file );
$meta_sizes = isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ? $meta['sizes'] : array(); $meta_sizes = isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ? $meta['sizes'] : array();
/** @var array<string, string> $files */ /* @var array<string, string> $files */
$files = array( 'original' => $base_path ); $files = array( 'original' => $base_path );
foreach ( $meta_sizes as $size ) { foreach ( $meta_sizes as $size ) {
if ( is_array( $size ) && isset( $size['file'] ) && is_string( $size['file'] ) ) { if ( is_array( $size ) && isset( $size['file'] ) && is_string( $size['file'] ) ) {
@ -157,8 +157,8 @@ class Media_Uploader {
$client = $this->client_factory->create(); $client = $this->client_factory->create();
$bucket = $this->config->get_bucket(); $bucket = $this->config->get_bucket();
$commands = array(); $commands = array();
$key_map = array(); // int index -> file metadata $key_map = array(); // int index -> file metadata.
$handles = array(); // int index -> resource $handles = array(); // int index -> resource.
$real_basedir = realpath( $basedir ); $real_basedir = realpath( $basedir );
$idx = 0; $idx = 0;

View file

@ -81,6 +81,13 @@ class Plugin {
*/ */
private $url_rewriter; private $url_rewriter;
/**
* Sub-size filter.
*
* @var Subsize_Filter
*/
private $subsize_filter;
/** /**
* Plugin file path. * Plugin file path.
* *
@ -110,6 +117,7 @@ class Plugin {
$this->admin_page = new Admin_Page( $this->config, $this->client_factory, $this->logger, $this->rate_limiter, $plugin_file ); $this->admin_page = new Admin_Page( $this->config, $this->client_factory, $this->logger, $this->rate_limiter, $plugin_file );
$this->media_uploader = new Media_Uploader( $this->config, $this->client_factory, $this->logger ); $this->media_uploader = new Media_Uploader( $this->config, $this->client_factory, $this->logger );
$this->url_rewriter = new URL_Rewriter( $this->config ); $this->url_rewriter = new URL_Rewriter( $this->config );
$this->subsize_filter = new Subsize_Filter( $this->config );
} }
/** /**
@ -145,6 +153,7 @@ class Plugin {
$this->admin_page->register(); $this->admin_page->register();
$this->media_uploader->register(); $this->media_uploader->register();
$this->url_rewriter->register(); $this->url_rewriter->register();
$this->subsize_filter->register();
} }
/** /**

View file

@ -0,0 +1,168 @@
<?php
/**
* Sub-size filter for iDrivee2 Media Upload.
*
* @package iDrivee2Media
* @since 1.3.0
*/
declare(strict_types=1);
namespace iDrivee2Media;
/**
* Prevent direct access to this file.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Filters the image sub-sizes generated by WordPress.
*
* Reduces the number of sub-sizes generated per uploaded image. Affects both
* the WP 7.1 client-side path (the browser reads the registered sub-sizes via
* the REST index) and the traditional server-side path
* (intermediate_image_sizes_advanced consumes the same list).
*
* Modes:
* - 'all' Default WordPress behavior; all registered sizes are generated.
* - 'thumbnail' Only the 'thumbnail' sub-size is generated.
* - 'none' No sub-sizes are generated; only the original is kept.
*
* In 'thumbnail' and 'none' modes the big_image_size_threshold is also
* disabled, so WordPress does not create a '-scaled' derivative for images
* larger than 2560px.
*
* @since 1.3.0
*/
class Subsize_Filter {
/**
* Mode: generate all registered sub-sizes (default).
*
* @var string
*/
public const MODE_ALL = 'all';
/**
* Mode: generate only the 'thumbnail' sub-size.
*
* @var string
*/
public const MODE_THUMBNAIL = 'thumbnail';
/**
* Mode: generate no sub-sizes.
*
* @var string
*/
public const MODE_NONE = 'none';
/**
* Allowed mode values.
*
* @var array<int, string>
*/
private const ALLOWED_MODES = array(
self::MODE_ALL,
self::MODE_THUMBNAIL,
self::MODE_NONE,
);
/**
* Configuration instance.
*
* @var Config
*/
private $config;
/**
* Constructor.
*
* @since 1.3.0
*
* @param Config $config Configuration instance.
*/
public function __construct( Config $config ) {
$this->config = $config;
}
/**
* Register the WordPress filters.
*
* Runs at priority 90 so user-supplied filters at the default priority
* (10) and at WP core's own guard priority (100) are not pre-empted.
*
* @since 1.3.0
*
* @return void
*/
public function register(): void {
add_filter( 'intermediate_image_sizes', array( $this, 'filter_intermediate_image_sizes' ), 90 );
add_filter( 'big_image_size_threshold', array( $this, 'filter_big_image_threshold' ), 90, 4 );
}
/**
* Filter the list of intermediate image sizes.
*
* This filter is the upstream source of truth consumed by
* wp_get_registered_image_subsizes(), which feeds both the REST index
* response (client-side path) and intermediate_image_sizes_advanced
* (server-side path).
*
* @since 1.3.0
*
* @param array<int, string> $sizes Registered image size names.
* @return array<int, string> Filtered size names.
*/
public function filter_intermediate_image_sizes( array $sizes ): array {
$mode = $this->effective_mode();
if ( self::MODE_ALL === $mode ) {
return $sizes;
}
if ( self::MODE_THUMBNAIL === $mode ) {
return in_array( 'thumbnail', $sizes, true ) ? array( 'thumbnail' ) : array();
}
return array();
}
/**
* Filter the big image size threshold.
*
* Returns 0 in 'thumbnail' and 'none' modes to disable the '-scaled'
* derivative that WordPress creates for images larger than the threshold.
*
* Note: WordPress fires this filter since 5.3. On older versions the
* callback is a no-op; the mode still applies via intermediate_image_sizes.
*
* @since 1.3.0
*
* @param int $threshold Threshold in pixels. Default 2560.
* @param array<int, int> $imagesize Indexed array of width and height.
* @param string $file Path to the uploaded file.
* @param int $attachment_id Attachment post ID.
* @return int Threshold in pixels, or 0 to disable.
*/
public function filter_big_image_threshold( int $threshold, array $imagesize = array(), string $file = '', int $attachment_id = 0 ): int {
if ( self::MODE_ALL === $this->effective_mode() ) {
return $threshold;
}
return 0;
}
/**
* Resolve the effective mode, defaulting to 'all' for any value that is
* not on the whitelist.
*
* @since 1.3.0
*
* @return string One of the MODE_* constants.
*/
private function effective_mode(): string {
$mode = $this->config->get_subsizes_mode();
return in_array( $mode, self::ALLOWED_MODES, true ) ? $mode : self::MODE_ALL;
}
}

Binary file not shown.

View file

@ -0,0 +1,230 @@
# Translation of iDrivee2 Media Upload 1.3.0 in Catalan.
# Copyright (C) 2026 ROBOTSTXT
# This file is distributed under the GPL-3.0-or-later license.
#
msgid ""
msgstr ""
"Project-Id-Version: iDrivee2 Media Upload 1.3.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/idrivee2-media-upload\n"
"POT-Creation-Date: 2026-07-18T13:20:23+00:00\n"
"PO-Revision-Date: 2026-07-18T13:30:00+00:00\n"
"Last-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"Language-Team: Català <ca@li.org>\n"
"Language: ca\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: idrivee2-media-upload\n"
#. Plugin Name of the plugin
msgid "iDrivee2 Media Upload"
msgstr "iDrivee2 Media Upload"
#. Plugin URI of the plugin
msgid "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload"
msgstr "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload"
#. Description of the plugin
msgid ""
"Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade "
"security and logging."
msgstr ""
"Puja fitxers multimèdia a iDrivee2 (compatible amb S3) amb seguretat de "
"nivell empresarial i registre."
#. Author of the plugin
msgid "ROBOTSTXT"
msgstr "ROBOTSTXT"
#. Author URI of the plugin
msgid "https://www.robotstxt.es/"
msgstr "https://www.robotstxt.es/"
#. translators: Admin menu title.
msgid "iDrivee2 Media Upload Settings"
msgstr "Ajusts d'iDrivee2 Media Upload"
#. translators: Admin menu label.
msgid "iDrivee2"
msgstr "iDrivee2"
msgid "You do not have sufficient permissions to access this page."
msgstr "No teniu permisos suficients per a accedir a aquesta pàgina."
#. translators: %d is the number of seconds to wait.
#, php-format
msgid "Please wait %d seconds before testing again."
msgstr "Espereu %d segons abans de tornar a provar-ho."
msgid "Configuration is incomplete. Please check your settings."
msgstr "La configuració és incompleta. Comproveu els ajusts."
msgid "Connection successful! Your S3 configuration is working correctly."
msgstr "S'ha connectat correctament! La configuració S3 funciona correctament."
#. translators: %s is the error message from AWS.
#, php-format
msgid "AWS Error: %s"
msgstr "Error d'AWS: %s"
msgid "Unknown error"
msgstr "Error desconegut"
#. translators: %s is the error message.
#, php-format
msgid "Error: %s"
msgstr "Error: %s"
#. translators: %d is the number of seconds to wait.
#, php-format
msgid "Please wait %d seconds before uploading again."
msgstr "Espereu %d segons abans de tornar a pujar-ne."
msgid "File uploaded successfully!"
msgstr "S'ha pujat el fitxer correctament!"
msgid "Invalid test file name format."
msgstr "El format del nom del fitxer de prova no és vàlid."
#. translators: %s is the file name.
#, php-format
msgid "File %s deleted successfully."
msgstr "S'ha eliminat el fitxer %s correctament."
msgid "Host URL must start with \"https://\"."
msgstr "La URL de l'amfitrió ha de començar per «https://»."
msgid ""
"Some settings are defined in wp-config.php and cannot be changed here. "
"These fields are shown as read-only."
msgstr ""
"Alguns ajusts estan definits a wp-config.php i no es poden canviar aquí. "
"Aquests camps es mostren com a només de lectura."
msgid "S3 Host"
msgstr "Amfitrió d'S3"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_HOST)"
msgstr "Definit a wp-config.php (IDRIVEE2_MEDIA_HOST)"
msgid "S3-compatible endpoint URL. Must start with \"https://\"."
msgstr "URL de l'endpoint compatible amb S3. Ha de començar per «https://»."
msgid "Access Key ID"
msgstr "ID de la clau d'accés"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_KEY)"
msgstr "Definit a wp-config.php (IDRIVEE2_MEDIA_KEY)"
msgid "Your S3 access key ID."
msgstr "L'ID de la clau d'accés d'S3."
msgid "Secret Access Key"
msgstr "Clau d'accés secreta"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_SECRET)"
msgstr "Definit a wp-config.php (IDRIVEE2_MEDIA_SECRET)"
msgid "Your S3 secret access key."
msgstr "La clau d'accés secreta d'S3."
msgid "Bucket Name"
msgstr "Nom del dipòsit"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_BUCKET)"
msgstr "Definit a wp-config.php (IDRIVEE2_MEDIA_BUCKET)"
msgid "The name of your S3 bucket."
msgstr "El nom del dipòsit S3."
msgid "Region"
msgstr "Regió"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_REGION)"
msgstr "Definit a wp-config.php (IDRIVEE2_MEDIA_REGION)"
msgid "AWS region (e.g., us-east-1, eu-west-1)."
msgstr "Regió d'AWS (per exemple, us-east-1, eu-west-1)."
msgid "Custom CDN Domain"
msgstr "Domini CDN personalitzat"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_DOMAIN)"
msgstr "Definit a wp-config.php (IDRIVEE2_MEDIA_DOMAIN)"
msgid "Optional: Custom domain for serving media files."
msgstr "Opcional: domini personalitzat per a servir fitxers multimèdia."
msgid "Image Sub-sizes"
msgstr "Submides de la imatge"
msgid "All registered sizes (WordPress default)"
msgstr "Totes les mides registrades (predeterminat del WordPress)"
msgid "Only thumbnail (faster uploads)"
msgstr "Només la miniatura (pujades més ràpides)"
msgid "No sub-sizes (original only)"
msgstr "Sense submides (només l'original)"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_SUBSIZES_MODE)"
msgstr "Definit a wp-config.php (IDRIVEE2_MEDIA_SUBSIZES_MODE)"
msgid ""
"Reduces work for large uploads (e.g. camera files). Also disables the -"
"scaled derivative in thumbnail/none modes."
msgstr ""
"Redueix la feina per a pujades grans (per exemple, fitxers de càmera). "
"També desactiva la derivada -scaled en els modes miniatura i cap."
msgid "Save Settings"
msgstr "Desa els ajusts"
msgid ""
"To modify settings defined in wp-config.php, please edit your wp-config.php "
"file directly."
msgstr ""
"Per a modificar els ajusts definits a wp-config.php, editeu directament "
"el fitxer wp-config.php."
msgid "Test Connection"
msgstr "Prova la connexió"
msgid "Test your S3 configuration to ensure everything is working correctly."
msgstr "Proveu la configuració S3 per a assegurar-vos que tot funciona correctament."
#. translators: %s is the file name.
#, php-format
msgid "File: %s"
msgstr "Fitxer: %s"
msgid "URL:"
msgstr "URL:"
msgid "Delete this file"
msgstr "Elimina aquest fitxer"
msgid "Test S3 Connection"
msgstr "Prova la connexió S3"
msgid "Verify that your S3 bucket is accessible with the configured credentials."
msgstr "Verifiqueu que el dipòsit S3 és accessible amb les credencials configurades."
msgid "Upload Test File"
msgstr "Puja un fitxer de prova"
msgid ""
"Upload a test file to S3 with a timestamped name (test-YYYYMMDDHHMMSS.txt). "
"The file will remain in S3 until manually deleted."
msgstr ""
"Puja un fitxer de prova a S3 amb un nom amb marca temporal "
"(test-YYYYMMDDHHMMSS.txt). El fitxer romandrà a S3 fins que s'elimini "
"manualment."
msgid "Every 5 Minutes"
msgstr "Cada 5 minuts"
msgid "Security check failed"
msgstr "Ha fallat la comprovació de seguretat"

Binary file not shown.

View file

@ -0,0 +1,230 @@
# Translation of iDrivee2 Media Upload 1.3.0 in Spanish (Spain).
# Copyright (C) 2026 ROBOTSTXT
# This file is distributed under the GPL-3.0-or-later license.
#
msgid ""
msgstr ""
"Project-Id-Version: iDrivee2 Media Upload 1.3.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/idrivee2-media-upload\n"
"POT-Creation-Date: 2026-07-18T13:20:23+00:00\n"
"PO-Revision-Date: 2026-07-18T13:30:00+00:00\n"
"Last-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"Language-Team: Español (España) <es@li.org>\n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: idrivee2-media-upload\n"
#. Plugin Name of the plugin
msgid "iDrivee2 Media Upload"
msgstr "iDrivee2 Media Upload"
#. Plugin URI of the plugin
msgid "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload"
msgstr "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload"
#. Description of the plugin
msgid ""
"Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade "
"security and logging."
msgstr ""
"Sube archivos multimedia a iDrivee2 (compatible con S3) con seguridad de "
"nivel empresarial y registro."
#. Author of the plugin
msgid "ROBOTSTXT"
msgstr "ROBOTSTXT"
#. Author URI of the plugin
msgid "https://www.robotstxt.es/"
msgstr "https://www.robotstxt.es/"
#. translators: Admin menu title.
msgid "iDrivee2 Media Upload Settings"
msgstr "Ajustes de iDrivee2 Media Upload"
#. translators: Admin menu label.
msgid "iDrivee2"
msgstr "iDrivee2"
msgid "You do not have sufficient permissions to access this page."
msgstr "No tienes permisos suficientes para acceder a esta página."
#. translators: %d is the number of seconds to wait.
#, php-format
msgid "Please wait %d seconds before testing again."
msgstr "Espera %d segundos antes de volver a probar."
msgid "Configuration is incomplete. Please check your settings."
msgstr "La configuración está incompleta. Revisa tus ajustes."
msgid "Connection successful! Your S3 configuration is working correctly."
msgstr "¡Conexión correcta! Tu configuración S3 funciona correctamente."
#. translators: %s is the error message from AWS.
#, php-format
msgid "AWS Error: %s"
msgstr "Error de AWS: %s"
msgid "Unknown error"
msgstr "Error desconocido"
#. translators: %s is the error message.
#, php-format
msgid "Error: %s"
msgstr "Error: %s"
#. translators: %d is the number of seconds to wait.
#, php-format
msgid "Please wait %d seconds before uploading again."
msgstr "Espera %d segundos antes de volver a subir."
msgid "File uploaded successfully!"
msgstr "¡Archivo subido correctamente!"
msgid "Invalid test file name format."
msgstr "Formato de nombre de archivo de prueba no válido."
#. translators: %s is the file name.
#, php-format
msgid "File %s deleted successfully."
msgstr "Archivo %s eliminado correctamente."
msgid "Host URL must start with \"https://\"."
msgstr "La URL del host debe empezar por «https://»."
msgid ""
"Some settings are defined in wp-config.php and cannot be changed here. "
"These fields are shown as read-only."
msgstr ""
"Algunos ajustes están definidos en wp-config.php y no se pueden cambiar "
"aquí. Estos campos se muestran como solo lectura."
msgid "S3 Host"
msgstr "Host de S3"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_HOST)"
msgstr "Definido en wp-config.php (IDRIVEE2_MEDIA_HOST)"
msgid "S3-compatible endpoint URL. Must start with \"https://\"."
msgstr "URL del endpoint compatible con S3. Debe empezar por «https://»."
msgid "Access Key ID"
msgstr "ID de la clave de acceso"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_KEY)"
msgstr "Definido en wp-config.php (IDRIVEE2_MEDIA_KEY)"
msgid "Your S3 access key ID."
msgstr "El ID de tu clave de acceso de S3."
msgid "Secret Access Key"
msgstr "Clave de acceso secreta"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_SECRET)"
msgstr "Definido en wp-config.php (IDRIVEE2_MEDIA_SECRET)"
msgid "Your S3 secret access key."
msgstr "Tu clave de acceso secreta de S3."
msgid "Bucket Name"
msgstr "Nombre del depósito"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_BUCKET)"
msgstr "Definido en wp-config.php (IDRIVEE2_MEDIA_BUCKET)"
msgid "The name of your S3 bucket."
msgstr "El nombre de tu depósito S3."
msgid "Region"
msgstr "Región"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_REGION)"
msgstr "Definido en wp-config.php (IDRIVEE2_MEDIA_REGION)"
msgid "AWS region (e.g., us-east-1, eu-west-1)."
msgstr "Región de AWS (por ejemplo, us-east-1, eu-west-1)."
msgid "Custom CDN Domain"
msgstr "Dominio CDN personalizado"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_DOMAIN)"
msgstr "Definido en wp-config.php (IDRIVEE2_MEDIA_DOMAIN)"
msgid "Optional: Custom domain for serving media files."
msgstr "Opcional: dominio personalizado para servir archivos multimedia."
msgid "Image Sub-sizes"
msgstr "Subtamaños de imagen"
msgid "All registered sizes (WordPress default)"
msgstr "Todos los tamaños registrados (predeterminado de WordPress)"
msgid "Only thumbnail (faster uploads)"
msgstr "Solo miniatura (subidas más rápidas)"
msgid "No sub-sizes (original only)"
msgstr "Sin subtamaños (solo el original)"
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_SUBSIZES_MODE)"
msgstr "Definido en wp-config.php (IDRIVEE2_MEDIA_SUBSIZES_MODE)"
msgid ""
"Reduces work for large uploads (e.g. camera files). Also disables the -"
"scaled derivative in thumbnail/none modes."
msgstr ""
"Reduce el trabajo en subidas grandes (por ejemplo, archivos de cámara). "
"También desactiva la derivada -scaled en los modos miniatura y ninguno."
msgid "Save Settings"
msgstr "Guardar ajustes"
msgid ""
"To modify settings defined in wp-config.php, please edit your wp-config.php "
"file directly."
msgstr ""
"Para modificar los ajustes definidos en wp-config.php, edita el archivo "
"wp-config.php directamente."
msgid "Test Connection"
msgstr "Probar conexión"
msgid "Test your S3 configuration to ensure everything is working correctly."
msgstr "Prueba tu configuración S3 para asegurarte de que todo funciona correctamente."
#. translators: %s is the file name.
#, php-format
msgid "File: %s"
msgstr "Archivo: %s"
msgid "URL:"
msgstr "URL:"
msgid "Delete this file"
msgstr "Eliminar este archivo"
msgid "Test S3 Connection"
msgstr "Probar conexión S3"
msgid "Verify that your S3 bucket is accessible with the configured credentials."
msgstr "Verifica que se puede acceder a tu depósito S3 con las credenciales configuradas."
msgid "Upload Test File"
msgstr "Subir archivo de prueba"
msgid ""
"Upload a test file to S3 with a timestamped name (test-YYYYMMDDHHMMSS.txt). "
"The file will remain in S3 until manually deleted."
msgstr ""
"Sube un archivo de prueba a S3 con un nombre con marca temporal "
"(test-YYYYMMDDHHMMSS.txt). El archivo permanecerá en S3 hasta que se "
"elimine manualmente."
msgid "Every 5 Minutes"
msgstr "Cada 5 minutos"
msgid "Security check failed"
msgstr "Ha fallado la comprobación de seguridad"

View file

@ -1,120 +1,275 @@
#, fuzzy # Copyright (C) 2026 ROBOTSTXT
# This file is distributed under the GPL-3.0-or-later.
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: iDrivee2 Media Upload\n" "Project-Id-Version: iDrivee2 Media Upload 1.3.0\n"
"POT-Creation-Date: 2025-08-06 15:40+0200\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/idrivee2-media-upload\n"
"PO-Revision-Date: 2025-08-06 15:40+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Last-Translator: \n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" "POT-Creation-Date: 2026-07-18T13:20:23+00:00\n"
"X-Generator: Poedit 3.6\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Poedit-Basepath: ..\n" "X-Generator: WP-CLI 2.12.0\n"
"X-Poedit-Flags-xgettext: --add-comments=translators:\n" "X-Domain: idrivee2-media-upload\n"
"X-Poedit-WPHeader: idrivee2-media-upload.php\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: "
"__;_e;_n:1,2;_x:1,2c;_ex:1,2c;_nx:4c,1,2;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c;_n_noop:1,2;_nx_noop:3c,1,2;__ngettext_noop:1,2\n"
"X-Poedit-SearchPath-0: .\n"
"X-Poedit-SearchPathExcluded-0: *.min.js\n"
"X-Poedit-SearchPathExcluded-1: vendor\n"
#. translators: Admin menu title. #. Plugin Name of the plugin
#. translators: Admin menu label. #: idrivee2-media-upload.php
#: idrivee2-media-upload.php:77 idrivee2-media-upload.php:79
msgid "iDrivee2"
msgstr ""
#: idrivee2-media-upload.php:122 idrivee2-media-upload.php:524
msgid "Test S3 Connection"
msgstr ""
#: idrivee2-media-upload.php:123
msgid "Testing..."
msgstr ""
#: idrivee2-media-upload.php:124 idrivee2-media-upload.php:529
msgid "Upload Test File"
msgstr ""
#: idrivee2-media-upload.php:125
msgid "Uploading..."
msgstr ""
#. translators: %s is the name of the missing constant.
#: idrivee2-media-upload.php:162
#, php-format
msgid "Missing constant %s."
msgstr ""
#: idrivee2-media-upload.php:184
msgid "Connection successful."
msgstr ""
#. translators: Error message when no file is provided.
#: idrivee2-media-upload.php:215
msgid "No file provided."
msgstr ""
#: idrivee2-media-upload.php:472
msgid "iDrivee2 Media Upload Settings"
msgstr ""
#: idrivee2-media-upload.php:476
msgid ""
"To use iDrivee2 Media Upload, please add these constants to wp-config.php:"
msgstr ""
#: idrivee2-media-upload.php:477
msgid "HOST must begin with \"https://\"."
msgstr ""
#: idrivee2-media-upload.php:489
msgid "Host"
msgstr ""
#: idrivee2-media-upload.php:493
msgid "Access Key"
msgstr ""
#: idrivee2-media-upload.php:497
msgid "Secret Key"
msgstr ""
#: idrivee2-media-upload.php:501
msgid "Bucket"
msgstr ""
#: idrivee2-media-upload.php:505
msgid "Region"
msgstr ""
#: idrivee2-media-upload.php:509
msgid "Domain"
msgstr ""
#: idrivee2-media-upload.php:515
msgid "Not defined"
msgstr ""
#. Plugin Name of the plugin/theme
msgid "iDrivee2 Media Upload" msgid "iDrivee2 Media Upload"
msgstr "" msgstr ""
#. Plugin URI of the plugin/theme #. Plugin URI of the plugin
#: idrivee2-media-upload.php
msgid "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload" msgid "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload"
msgstr "" msgstr ""
#. Description of the plugin/theme #. Description of the plugin
msgid "Uploads media files to iDrivee2 (S3-compatible)." #: idrivee2-media-upload.php
msgid "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging."
msgstr "" msgstr ""
#. Author of the plugin/theme #. Author of the plugin
#: idrivee2-media-upload.php
msgid "ROBOTSTXT" msgid "ROBOTSTXT"
msgstr "" msgstr ""
#. Author URI of the plugin/theme #. Author URI of the plugin
#: idrivee2-media-upload.php
msgid "https://www.robotstxt.es/" msgid "https://www.robotstxt.es/"
msgstr "" msgstr ""
#. translators: Admin menu title.
#: includes/class-admin-page.php:109
#: includes/class-admin-page.php:595
msgid "iDrivee2 Media Upload Settings"
msgstr ""
#. translators: Admin menu label.
#: includes/class-admin-page.php:111
msgid "iDrivee2"
msgstr ""
#: includes/class-admin-page.php:156
#: includes/class-admin-page.php:168
#: includes/class-admin-page.php:180
#: robotstxt-updater.php:394
msgid "You do not have sufficient permissions to access this page."
msgstr ""
#. translators: %d is the number of seconds to wait.
#: includes/class-admin-page.php:206
#, php-format
msgid "Please wait %d seconds before testing again."
msgstr ""
#: includes/class-admin-page.php:222
msgid "Configuration is incomplete. Please check your settings."
msgstr ""
#: includes/class-admin-page.php:243
msgid "Connection successful! Your S3 configuration is working correctly."
msgstr ""
#. translators: %s is the error message from AWS.
#: includes/class-admin-page.php:256
#: includes/class-admin-page.php:361
#: includes/class-admin-page.php:471
#, php-format
msgid "AWS Error: %s"
msgstr ""
#: includes/class-admin-page.php:257
#: includes/class-admin-page.php:362
#: includes/class-admin-page.php:472
msgid "Unknown error"
msgstr ""
#. translators: %s is the error message.
#: includes/class-admin-page.php:271
#: includes/class-admin-page.php:376
#: includes/class-admin-page.php:486
#, php-format
msgid "Error: %s"
msgstr ""
#. translators: %d is the number of seconds to wait.
#: includes/class-admin-page.php:300
#, php-format
msgid "Please wait %d seconds before uploading again."
msgstr ""
#: includes/class-admin-page.php:345
msgid "File uploaded successfully!"
msgstr ""
#: includes/class-admin-page.php:426
msgid "Invalid test file name format."
msgstr ""
#. translators: %s is the file name.
#: includes/class-admin-page.php:455
#, php-format
msgid "File %s deleted successfully."
msgstr ""
#: includes/class-admin-page.php:517
msgid "Host URL must start with \"https://\"."
msgstr ""
#: includes/class-admin-page.php:601
msgid "Some settings are defined in wp-config.php and cannot be changed here. These fields are shown as read-only."
msgstr ""
#: includes/class-admin-page.php:612
msgid "S3 Host"
msgstr ""
#: includes/class-admin-page.php:627
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_HOST)"
msgstr ""
#: includes/class-admin-page.php:629
msgid "S3-compatible endpoint URL. Must start with \"https://\"."
msgstr ""
#: includes/class-admin-page.php:638
msgid "Access Key ID"
msgstr ""
#: includes/class-admin-page.php:653
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_KEY)"
msgstr ""
#: includes/class-admin-page.php:655
msgid "Your S3 access key ID."
msgstr ""
#: includes/class-admin-page.php:664
msgid "Secret Access Key"
msgstr ""
#: includes/class-admin-page.php:679
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_SECRET)"
msgstr ""
#: includes/class-admin-page.php:681
msgid "Your S3 secret access key."
msgstr ""
#: includes/class-admin-page.php:690
msgid "Bucket Name"
msgstr ""
#: includes/class-admin-page.php:705
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_BUCKET)"
msgstr ""
#: includes/class-admin-page.php:707
msgid "The name of your S3 bucket."
msgstr ""
#: includes/class-admin-page.php:716
msgid "Region"
msgstr ""
#: includes/class-admin-page.php:732
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_REGION)"
msgstr ""
#: includes/class-admin-page.php:734
msgid "AWS region (e.g., us-east-1, eu-west-1)."
msgstr ""
#: includes/class-admin-page.php:743
msgid "Custom CDN Domain"
msgstr ""
#: includes/class-admin-page.php:758
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_DOMAIN)"
msgstr ""
#: includes/class-admin-page.php:760
msgid "Optional: Custom domain for serving media files."
msgstr ""
#: includes/class-admin-page.php:769
msgid "Image Sub-sizes"
msgstr ""
#: includes/class-admin-page.php:779
msgid "All registered sizes (WordPress default)"
msgstr ""
#: includes/class-admin-page.php:780
msgid "Only thumbnail (faster uploads)"
msgstr ""
#: includes/class-admin-page.php:781
msgid "No sub-sizes (original only)"
msgstr ""
#: includes/class-admin-page.php:806
msgid "Defined in wp-config.php (IDRIVEE2_MEDIA_SUBSIZES_MODE)"
msgstr ""
#: includes/class-admin-page.php:808
msgid "Reduces work for large uploads (e.g. camera files). Also disables the -scaled derivative in thumbnail/none modes."
msgstr ""
#: includes/class-admin-page.php:818
#: includes/class-admin-page.php:828
msgid "Save Settings"
msgstr ""
#: includes/class-admin-page.php:821
msgid "To modify settings defined in wp-config.php, please edit your wp-config.php file directly."
msgstr ""
#: includes/class-admin-page.php:836
#: includes/class-admin-page.php:887
msgid "Test Connection"
msgstr ""
#: includes/class-admin-page.php:838
msgid "Test your S3 configuration to ensure everything is working correctly."
msgstr ""
#. translators: %s is the file name.
#: includes/class-admin-page.php:860
#, php-format
msgid "File: %s"
msgstr ""
#: includes/class-admin-page.php:866
msgid "URL:"
msgstr ""
#: includes/class-admin-page.php:875
msgid "Delete this file"
msgstr ""
#: includes/class-admin-page.php:892
msgid "Test S3 Connection"
msgstr ""
#: includes/class-admin-page.php:895
msgid "Verify that your S3 bucket is accessible with the configured credentials."
msgstr ""
#: includes/class-admin-page.php:901
#: includes/class-admin-page.php:906
msgid "Upload Test File"
msgstr ""
#: includes/class-admin-page.php:909
msgid "Upload a test file to S3 with a timestamped name (test-YYYYMMDDHHMMSS.txt). The file will remain in S3 until manually deleted."
msgstr ""
#: includes/class-plugin.php:170
msgid "Every 5 Minutes"
msgstr ""
#: robotstxt-updater.php:389
msgid "Security check failed"
msgstr ""

View file

@ -3,9 +3,9 @@ Contributors: robotstxt, javiercasares
Tags: media, upload, s3, cdn, storage, idrivee2, cloud Tags: media, upload, s3, cdn, storage, idrivee2, cloud
Requires at least: 4.1 Requires at least: 4.1
Tested up to: 7.1 Tested up to: 7.1
Stable tag: 1.2.1 Stable tag: 1.3.0
Requires PHP: 8.1 Requires PHP: 8.1
Version: 1.2.1 Version: 1.3.0
License: GPL-3.0-or-later License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -199,6 +199,41 @@ PHP 8.2 or higher is required. The plugin uses strict type declarations and is t
== Changelog == == Changelog ==
= 1.3.0 =
_Release date: 2026-07-18_
**Highlights**
* New image sub-sizes control — ship less data per upload, ideal for large camera files. Reduces work in both the WP 7.1 client-side path (browser) and the traditional server-side path.
**Added**
* Settings field **Image Sub-sizes** under Settings → iDrivee2 with three modes: `all` (default), `thumbnail` (only the default thumbnail), `none` (no sub-sizes).
* `IDRIVEE2_MEDIA_SUBSIZES_MODE` wp-config.php constant — same priority pattern as the S3 credentials; the field becomes read-only when set.
* In `thumbnail` and `none` modes the `big_image_size_threshold` filter is also disabled, so WordPress no longer creates a `-scaled` derivative for images larger than 2560px. (Requires WordPress 5.3+ for the scaled-disabling; on older WP the mode still applies via `intermediate_image_sizes`.)
**Security**
* `composer audit` CVEs resolved: `guzzlehttp/guzzle` 7.11 → 7.15, `guzzlehttp/psr7` 2.11 → 2.13, `mtdowling/jmespath.php` 2.8 → 2.9 (CVE-2026-55767 / 55568 / 55766 / 54133).
**Tooling**
* `bin/preflight.sh` added — automated pre-deploy verification per AGENTS-testing-build-deployment.md (PHPCS, PHPStan, PHPCompatibility, PHPUnit + coverage, composer audit, candidate ZIP inspection).
* `.claude/settings.json` added — mechanical deny rules for `deploy.sh`, `git push/tag/merge` per AGENTS.md.
**Compatibility**
* WordPress: 4.1 - 7.1
* PHP: 8.1 - 8.5
**Tests**
* PHP Coding Standards: 3.13.5 (0 errors)
* WordPress Coding Standards: 3.3.0 (0 violations)
* PHPStan: Level 9, 0 errors
* PHPUnit: 38 tests, 60 assertions
= 1.2.1 = = 1.2.1 =
_Release date: 2026-06-05_ _Release date: 2026-06-05_

View file

@ -1,20 +1,20 @@
{ {
"name": "iDrivee2 Media Upload", "name": "iDrivee2 Media Upload",
"slug": "idrivee2-media-upload", "slug": "idrivee2-media-upload",
"version": "1.2.1", "version": "1.3.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload/releases/download/1.2.1/idrivee2-media-upload-1.2.1.zip", "download_url": "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload/releases/download/1.3.0/idrivee2-media-upload-1.3.0.zip",
"requires": "4.1", "requires": "4.1",
"requires_php": "8.1", "requires_php": "8.1",
"tested": "7.1", "tested": "7.1",
"last_updated": "2026-06-05", "last_updated": "2026-07-18",
"author": "ROBOTSTXT", "author": "ROBOTSTXT",
"author_profile": "https://www.robotstxt.es/", "author_profile": "https://www.robotstxt.es/",
"homepage": "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload", "homepage": "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload",
"description": "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging.", "description": "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging.",
"changelog": "<h3>1.2.1 - 2026-06-05</h3><ul><li><strong>Fixed:</strong> Infinite recursion on image upload — wp_update_post() fired edit_attachment which re-triggered the upload method. Removed edit_attachment hook, added re-entry guard.</li><li><strong>Fixed:</strong> Fatal TypeError: fclose() on already-closed stream — AWS SDK closes streams after upload; added is_resource() check before fclose().</li></ul><h3>1.2.0 - 2026-06-02</h3><ul><li><strong>Performance:</strong> Concurrent S3 uploads via AWS CommandPool (default 5, tunable via IDRIVEE2_UPLOAD_CONCURRENCY)</li><li><strong>Performance:</strong> Stream files directly from disk — no full load into memory</li><li><strong>Performance:</strong> Removed per-file headObject pre-check — single batch DB write for stats</li><li><strong>Changed:</strong> Hook priority lowered from 999 to 10</li></ul><h3>1.1.4 - 2026-06-02</h3><ul><li><strong>Added:</strong> Full dev tooling: PHPCS, PHPStan level 9, PHPUnit test suite (22 tests)</li><li><strong>Fixed:</strong> WP_Filesystem null guard, type safety on get_option/get_transient, dynamic asset version</li><li><strong>Changed:</strong> Tested up to WordPress 7.1, PHP 8.1-8.5, Requires at least 4.1</li></ul><h3>1.1.3 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Critical namespace issue with Robotstxt_Updater class causing fatal error</li><li><strong>Fixed:</strong> Plugin now loads correctly without PHP fatal errors</li></ul><h3>1.1.2 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Deployment script updated to use PHP 8.2 as platform base for production builds</li><li><strong>Changed:</strong> Now uses composer update --no-dev for consistent dependency resolution</li><li><strong>Improved:</strong> Production packages guarantee PHP 8.2+ compatibility regardless of dev environment</li></ul><h3>1.1.1 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Deployment script now includes essential files (update.json, robotstxt-updater.php, readme.txt, changelog.txt)</li><li><strong>Improved:</strong> Production packages now contain all files required for automatic updates from Gitea</li></ul><h3>1.1.0 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Added explicit PHP version requirement (>=8.2) to composer.json</li><li><strong>Changed:</strong> Updated update.json with correct plugin information</li><li><strong>Changed:</strong> Fixed Text Domain in robotstxt-updater.php to match plugin slug</li><li><strong>Fixed:</strong> Composer now validates PHP version during dependency installation</li><li><strong>Fixed:</strong> Plugin update system correctly identifies the plugin</li><li><strong>Fixed:</strong> Translations properly loaded for updater error messages</li><li><strong>Improved:</strong> All text domains now consistently use 'idrivee2-media-upload'</li></ul><h3>1.0.0 - 2026-02-03</h3><ul><li><strong>Release:</strong> First stable release</li><li><strong>Feature:</strong> Automatic upload of media files to iDrivee2 (S3-compatible storage)</li><li><strong>Feature:</strong> URL rewriting to serve media from CDN</li><li><strong>Feature:</strong> Local file deletion after successful upload</li><li><strong>Feature:</strong> Admin interface with connection and upload testing</li><li><strong>Security:</strong> Enterprise-grade security with nonce validation</li><li><strong>Architecture:</strong> Class-based modular architecture with dependency injection</li><li><strong>Testing:</strong> PHPUnit test structure and PHPStan static analysis</li><li><strong>Compatibility:</strong> WordPress 6.8+ and PHP 8.2+</li></ul>", "changelog": "<h3>1.3.0 - 2026-07-18</h3><ul><li><strong>Added:</strong> Image sub-sizes control under Settings → iDrivee2 — three modes: all (default), thumbnail, none.</li><li><strong>Added:</strong> IDRIVEE2_MEDIA_SUBSIZES_MODE wp-config.php constant; UI field becomes read-only when set.</li><li><strong>Added:</strong> In thumbnail/none modes, the -scaled derivative (big_image_size_threshold) is also disabled.</li><li><strong>Security:</strong> composer audit CVEs resolved — guzzlehttp/guzzle 7.11→7.15, guzzlehttp/psr7 2.11→2.13, mtdowling/jmespath.php 2.8→2.9 (CVE-2026-55767/55568/55766/54133).</li></ul><h3>1.2.1 - 2026-06-05</h3><ul><li><strong>Fixed:</strong> Infinite recursion on image upload — wp_update_post() fired edit_attachment which re-triggered the upload method. Removed edit_attachment hook, added re-entry guard.</li><li><strong>Fixed:</strong> Fatal TypeError: fclose() on already-closed stream — AWS SDK closes streams after upload; added is_resource() check before fclose().</li></ul><h3>1.2.0 - 2026-06-02</h3><ul><li><strong>Performance:</strong> Concurrent S3 uploads via AWS CommandPool (default 5, tunable via IDRIVEE2_UPLOAD_CONCURRENCY)</li><li><strong>Performance:</strong> Stream files directly from disk — no full load into memory</li><li><strong>Performance:</strong> Removed per-file headObject pre-check — single batch DB write for stats</li><li><strong>Changed:</strong> Hook priority lowered from 999 to 10</li></ul><h3>1.1.4 - 2026-06-02</h3><ul><li><strong>Added:</strong> Full dev tooling: PHPCS, PHPStan level 9, PHPUnit test suite (22 tests)</li><li><strong>Fixed:</strong> WP_Filesystem null guard, type safety on get_option/get_transient, dynamic asset version</li><li><strong>Changed:</strong> Tested up to WordPress 7.1, PHP 8.1-8.5, Requires at least 4.1</li></ul><h3>1.1.3 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Critical namespace issue with Robotstxt_Updater class causing fatal error</li><li><strong>Fixed:</strong> Plugin now loads correctly without PHP fatal errors</li></ul><h3>1.1.2 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Deployment script updated to use PHP 8.2 as platform base for production builds</li><li><strong>Changed:</strong> Now uses composer update --no-dev for consistent dependency resolution</li><li><strong>Improved:</strong> Production packages guarantee PHP 8.2+ compatibility regardless of dev environment</li></ul><h3>1.1.1 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Deployment script now includes essential files (update.json, robotstxt-updater.php, readme.txt, changelog.txt)</li><li><strong>Improved:</strong> Production packages now contain all files required for automatic updates from Gitea</li></ul><h3>1.1.0 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Added explicit PHP version requirement (>=8.2) to composer.json</li><li><strong>Changed:</strong> Updated update.json with correct plugin information</li><li><strong>Changed:</strong> Fixed Text Domain in robotstxt-updater.php to match plugin slug</li><li><strong>Fixed:</strong> Composer now validates PHP version during dependency installation</li><li><strong>Fixed:</strong> Plugin update system correctly identifies the plugin</li><li><strong>Fixed:</strong> Translations properly loaded for updater error messages</li><li><strong>Improved:</strong> All text domains now consistently use 'idrivee2-media-upload'</li></ul><h3>1.0.0 - 2026-02-03</h3><ul><li><strong>Release:</strong> First stable release</li><li><strong>Feature:</strong> Automatic upload of media files to iDrivee2 (S3-compatible storage)</li><li><strong>Feature:</strong> URL rewriting to serve media from CDN</li><li><strong>Feature:</strong> Local file deletion after successful upload</li><li><strong>Feature:</strong> Admin interface with connection and upload testing</li><li><strong>Security:</strong> Enterprise-grade security with nonce validation</li><li><strong>Architecture:</strong> Class-based modular architecture with dependency injection</li><li><strong>Testing:</strong> PHPUnit test structure and PHPStan static analysis</li><li><strong>Compatibility:</strong> WordPress 6.8+ and PHP 8.2+</li></ul>",
"sections": { "sections": {
"description": "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging. The plugin intercepts WordPress media uploads, pushes files to an S3-compatible bucket, deletes local copies, and rewrites URLs to serve media from the CDN.", "description": "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging. The plugin intercepts WordPress media uploads, pushes files to an S3-compatible bucket, deletes local copies, and rewrites URLs to serve media from the CDN.",
"changelog": "<h3>1.2.1 - 2026-06-05</h3><ul><li><strong>Fixed:</strong> Infinite recursion on image upload — wp_update_post() fired edit_attachment which re-triggered the upload method. Removed edit_attachment hook, added re-entry guard.</li><li><strong>Fixed:</strong> Fatal TypeError: fclose() on already-closed stream — AWS SDK closes streams after upload; added is_resource() check before fclose().</li></ul><h3>1.2.0 - 2026-06-02</h3><ul><li><strong>Performance:</strong> Concurrent S3 uploads via AWS CommandPool (default 5, tunable via IDRIVEE2_UPLOAD_CONCURRENCY)</li><li><strong>Performance:</strong> Stream files directly from disk — no full load into memory</li><li><strong>Performance:</strong> Removed per-file headObject pre-check — single batch DB write for stats</li><li><strong>Changed:</strong> Hook priority lowered from 999 to 10</li></ul><h3>1.1.4 - 2026-06-02</h3><ul><li><strong>Added:</strong> Full dev tooling: PHPCS, PHPStan level 9, PHPUnit test suite (22 tests)</li><li><strong>Fixed:</strong> WP_Filesystem null guard, type safety on get_option/get_transient, dynamic asset version</li><li><strong>Changed:</strong> Tested up to WordPress 7.1, PHP 8.1-8.5, Requires at least 4.1</li></ul><h3>1.1.3 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Critical namespace issue with Robotstxt_Updater class causing fatal error</li><li><strong>Fixed:</strong> Plugin now loads correctly without PHP fatal errors</li></ul><h3>1.1.2 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Deployment script updated to use PHP 8.2 as platform base for production builds</li><li><strong>Changed:</strong> Now uses composer update --no-dev for consistent dependency resolution</li><li><strong>Improved:</strong> Production packages guarantee PHP 8.2+ compatibility regardless of dev environment</li></ul><h3>1.1.1 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Deployment script now includes essential files (update.json, robotstxt-updater.php, readme.txt, changelog.txt)</li><li><strong>Improved:</strong> Production packages now contain all files required for automatic updates from Gitea</li></ul><h3>1.1.0 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Added explicit PHP version requirement (>=8.2) to composer.json</li><li><strong>Changed:</strong> Updated update.json with correct plugin information</li><li><strong>Changed:</strong> Fixed Text Domain in robotstxt-updater.php to match plugin slug</li><li><strong>Fixed:</strong> Composer now validates PHP version during dependency installation</li><li><strong>Fixed:</strong> Plugin update system correctly identifies the plugin</li><li><strong>Fixed:</strong> Translations properly loaded for updater error messages</li><li><strong>Improved:</strong> All text domains now consistently use 'idrivee2-media-upload'</li></ul><h3>1.0.0 - 2026-02-03</h3><ul><li><strong>Release:</strong> First stable release</li><li><strong>Feature:</strong> Automatic upload of media files to iDrivee2 (S3-compatible storage)</li><li><strong>Feature:</strong> URL rewriting to serve media from CDN</li><li><strong>Feature:</strong> Local file deletion after successful upload</li><li><strong>Feature:</strong> Admin interface with connection and upload testing</li><li><strong>Security:</strong> Enterprise-grade security with nonce validation</li><li><strong>Architecture:</strong> Class-based modular architecture with dependency injection</li><li><strong>Testing:</strong> PHPUnit test structure and PHPStan static analysis</li><li><strong>Compatibility:</strong> WordPress 6.8+ and PHP 8.2+</li></ul>" "changelog": "<h3>1.3.0 - 2026-07-18</h3><ul><li><strong>Added:</strong> Image sub-sizes control under Settings → iDrivee2 — three modes: all (default), thumbnail, none.</li><li><strong>Added:</strong> IDRIVEE2_MEDIA_SUBSIZES_MODE wp-config.php constant; UI field becomes read-only when set.</li><li><strong>Added:</strong> In thumbnail/none modes, the -scaled derivative (big_image_size_threshold) is also disabled.</li><li><strong>Security:</strong> composer audit CVEs resolved — guzzlehttp/guzzle 7.11→7.15, guzzlehttp/psr7 2.11→2.13, mtdowling/jmespath.php 2.8→2.9 (CVE-2026-55767/55568/55766/54133).</li></ul><h3>1.2.1 - 2026-06-05</h3><ul><li><strong>Fixed:</strong> Infinite recursion on image upload — wp_update_post() fired edit_attachment which re-triggered the upload method. Removed edit_attachment hook, added re-entry guard.</li><li><strong>Fixed:</strong> Fatal TypeError: fclose() on already-closed stream — AWS SDK closes streams after upload; added is_resource() check before fclose().</li></ul><h3>1.2.0 - 2026-06-02</h3><ul><li><strong>Performance:</strong> Concurrent S3 uploads via AWS CommandPool (default 5, tunable via IDRIVEE2_UPLOAD_CONCURRENCY)</li><li><strong>Performance:</strong> Stream files directly from disk — no full load into memory</li><li><strong>Performance:</strong> Removed per-file headObject pre-check — single batch DB write for stats</li><li><strong>Changed:</strong> Hook priority lowered from 999 to 10</li></ul><h3>1.1.4 - 2026-06-02</h3><ul><li><strong>Added:</strong> Full dev tooling: PHPCS, PHPStan level 9, PHPUnit test suite (22 tests)</li><li><strong>Fixed:</strong> WP_Filesystem null guard, type safety on get_option/get_transient, dynamic asset version</li><li><strong>Changed:</strong> Tested up to WordPress 7.1, PHP 8.1-8.5, Requires at least 4.1</li></ul><h3>1.1.3 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Critical namespace issue with Robotstxt_Updater class causing fatal error</li><li><strong>Fixed:</strong> Plugin now loads correctly without PHP fatal errors</li></ul><h3>1.1.2 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Deployment script updated to use PHP 8.2 as platform base for production builds</li><li><strong>Changed:</strong> Now uses composer update --no-dev for consistent dependency resolution</li><li><strong>Improved:</strong> Production packages guarantee PHP 8.2+ compatibility regardless of dev environment</li></ul><h3>1.1.1 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Deployment script now includes essential files (update.json, robotstxt-updater.php, readme.txt, changelog.txt)</li><li><strong>Improved:</strong> Production packages now contain all files required for automatic updates from Gitea</li></ul><h3>1.1.0 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Added explicit PHP version requirement (>=8.2) to composer.json</li><li><strong>Changed:</strong> Updated update.json with correct plugin information</li><li><strong>Changed:</strong> Fixed Text Domain in robotstxt-updater.php to match plugin slug</li><li><strong>Fixed:</strong> Composer now validates PHP version during dependency installation</li><li><strong>Fixed:</strong> Plugin update system correctly identifies the plugin</li><li><strong>Fixed:</strong> Translations properly loaded for updater error messages</li><li><strong>Improved:</strong> All text domains now consistently use 'idrivee2-media-upload'</li></ul><h3>1.0.0 - 2026-02-03</h3><ul><li><strong>Release:</strong> First stable release</li><li><strong>Feature:</strong> Automatic upload of media files to iDrivee2 (S3-compatible storage)</li><li><strong>Feature:</strong> URL rewriting to serve media from CDN</li><li><strong>Feature:</strong> Local file deletion after successful upload</li><li><strong>Feature:</strong> Admin interface with connection and upload testing</li><li><strong>Security:</strong> Enterprise-grade security with nonce validation</li><li><strong>Architecture:</strong> Class-based modular architecture with dependency injection</li><li><strong>Testing:</strong> PHPUnit test structure and PHPStan static analysis</li><li><strong>Compatibility:</strong> WordPress 6.8+ and PHP 8.2+</li></ul>"
}, },
"banners": { "banners": {
"low": "", "low": "",

View file

@ -1,4 +0,0 @@
## Code of Conduct
This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
opensource-codeofconduct@amazon.com with any additional questions or comments.

View file

@ -1,4 +0,0 @@
## Building and enabling the Common Run Time
1. **Follow instructions on crt repo** Clone and build the repo as shown [here][https://github.com/awslabs/aws-crt-php].
1. **Enable the CRT** add the following line to your php.ini file `extension=path/to/aws-crt-php/modules/awscrt.so`

View file

@ -1,19 +0,0 @@
# Overview
This page describes the support policy for the Amazon S3 Encryption Client for PHP. We regularly provide the Amazon S3 Encryption Client for PHP with updates that may contain support for new or updated APIs, new features, enhancements, bug fixes, security patches, or documentation updates. Updates may also address changes with dependencies, language runtimes, and operating systems.
We recommend users to stay up-to-date with Amazon S3 Encryption Client for PHP releases to keep up with the latest features, security updates, and underlying dependencies. Continued use of an unsupported SDK version is not recommended and is done at the user's discretion.
# Major Version Lifecycle
The Amazon S3 Encryption Client for Go follows the same major version lifecycle as the AWS SDK. For details on this lifecycle, see [AWS SDKs and Tools Maintenance Policy](https://docs.aws.amazon.com/sdkref/latest/guide/maint-policy.html#version-life-cycle).
# Version Support Matrix
This table describes the current support status of each major version of the Amazon S3 Encryption Client for PHP. It also shows the next status each major version will transition to, and the date at which that transition will happen.
| Major version | Current status | Next status | Next status date |
|--------------|----------------|-------------|------------------|
| 3.x | General Availability | - | - |
| 2.x | General Availability | Maintenance | 2026-06-15 |
| 1.x | End of Support | - | - |

View file

@ -1299,14 +1299,18 @@ return array(
'GuzzleHttp\\Handler\\CurlHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/CurlHandler.php', 'GuzzleHttp\\Handler\\CurlHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/CurlHandler.php',
'GuzzleHttp\\Handler\\CurlMultiHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php', 'GuzzleHttp\\Handler\\CurlMultiHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php',
'GuzzleHttp\\Handler\\CurlShareHandleState' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/CurlShareHandleState.php', 'GuzzleHttp\\Handler\\CurlShareHandleState' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/CurlShareHandleState.php',
'GuzzleHttp\\Handler\\CurlVersion' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/CurlVersion.php',
'GuzzleHttp\\Handler\\EasyHandle' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/EasyHandle.php', 'GuzzleHttp\\Handler\\EasyHandle' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/EasyHandle.php',
'GuzzleHttp\\Handler\\HeaderProcessor' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/HeaderProcessor.php', 'GuzzleHttp\\Handler\\HeaderProcessor' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/HeaderProcessor.php',
'GuzzleHttp\\Handler\\MockHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/MockHandler.php', 'GuzzleHttp\\Handler\\MockHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/MockHandler.php',
'GuzzleHttp\\Handler\\Proxy' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/Proxy.php', 'GuzzleHttp\\Handler\\Proxy' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/Proxy.php',
'GuzzleHttp\\Handler\\ProxyEnvironment' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/ProxyEnvironment.php',
'GuzzleHttp\\Handler\\StreamHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/StreamHandler.php', 'GuzzleHttp\\Handler\\StreamHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/StreamHandler.php',
'GuzzleHttp\\Handler\\TlsVersion' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/TlsVersion.php',
'GuzzleHttp\\MessageFormatter' => $vendorDir . '/guzzlehttp/guzzle/src/MessageFormatter.php', 'GuzzleHttp\\MessageFormatter' => $vendorDir . '/guzzlehttp/guzzle/src/MessageFormatter.php',
'GuzzleHttp\\MessageFormatterInterface' => $vendorDir . '/guzzlehttp/guzzle/src/MessageFormatterInterface.php', 'GuzzleHttp\\MessageFormatterInterface' => $vendorDir . '/guzzlehttp/guzzle/src/MessageFormatterInterface.php',
'GuzzleHttp\\Middleware' => $vendorDir . '/guzzlehttp/guzzle/src/Middleware.php', 'GuzzleHttp\\Middleware' => $vendorDir . '/guzzlehttp/guzzle/src/Middleware.php',
'GuzzleHttp\\Multiplexing' => $vendorDir . '/guzzlehttp/guzzle/src/Multiplexing.php',
'GuzzleHttp\\Pool' => $vendorDir . '/guzzlehttp/guzzle/src/Pool.php', 'GuzzleHttp\\Pool' => $vendorDir . '/guzzlehttp/guzzle/src/Pool.php',
'GuzzleHttp\\PrepareBodyMiddleware' => $vendorDir . '/guzzlehttp/guzzle/src/PrepareBodyMiddleware.php', 'GuzzleHttp\\PrepareBodyMiddleware' => $vendorDir . '/guzzlehttp/guzzle/src/PrepareBodyMiddleware.php',
'GuzzleHttp\\Promise\\AggregateException' => $vendorDir . '/guzzlehttp/promises/src/AggregateException.php', 'GuzzleHttp\\Promise\\AggregateException' => $vendorDir . '/guzzlehttp/promises/src/AggregateException.php',
@ -1414,5 +1418,6 @@ return array(
'iDrivee2Media\\Plugin' => $baseDir . '/includes/class-plugin.php', 'iDrivee2Media\\Plugin' => $baseDir . '/includes/class-plugin.php',
'iDrivee2Media\\Rate_Limiter' => $baseDir . '/includes/class-rate-limiter.php', 'iDrivee2Media\\Rate_Limiter' => $baseDir . '/includes/class-rate-limiter.php',
'iDrivee2Media\\S3_Client_Factory' => $baseDir . '/includes/class-s3-client-factory.php', 'iDrivee2Media\\S3_Client_Factory' => $baseDir . '/includes/class-s3-client-factory.php',
'iDrivee2Media\\Subsize_Filter' => $baseDir . '/includes/class-subsize-filter.php',
'iDrivee2Media\\URL_Rewriter' => $baseDir . '/includes/class-url-rewriter.php', 'iDrivee2Media\\URL_Rewriter' => $baseDir . '/includes/class-url-rewriter.php',
); );

View file

@ -1388,14 +1388,18 @@ class ComposerStaticInite60858b25bb9b11d51011ff69c492447
'GuzzleHttp\\Handler\\CurlHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/CurlHandler.php', 'GuzzleHttp\\Handler\\CurlHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/CurlHandler.php',
'GuzzleHttp\\Handler\\CurlMultiHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php', 'GuzzleHttp\\Handler\\CurlMultiHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php',
'GuzzleHttp\\Handler\\CurlShareHandleState' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/CurlShareHandleState.php', 'GuzzleHttp\\Handler\\CurlShareHandleState' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/CurlShareHandleState.php',
'GuzzleHttp\\Handler\\CurlVersion' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/CurlVersion.php',
'GuzzleHttp\\Handler\\EasyHandle' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/EasyHandle.php', 'GuzzleHttp\\Handler\\EasyHandle' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/EasyHandle.php',
'GuzzleHttp\\Handler\\HeaderProcessor' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/HeaderProcessor.php', 'GuzzleHttp\\Handler\\HeaderProcessor' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/HeaderProcessor.php',
'GuzzleHttp\\Handler\\MockHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/MockHandler.php', 'GuzzleHttp\\Handler\\MockHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/MockHandler.php',
'GuzzleHttp\\Handler\\Proxy' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/Proxy.php', 'GuzzleHttp\\Handler\\Proxy' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/Proxy.php',
'GuzzleHttp\\Handler\\ProxyEnvironment' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/ProxyEnvironment.php',
'GuzzleHttp\\Handler\\StreamHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/StreamHandler.php', 'GuzzleHttp\\Handler\\StreamHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/StreamHandler.php',
'GuzzleHttp\\Handler\\TlsVersion' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/TlsVersion.php',
'GuzzleHttp\\MessageFormatter' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/MessageFormatter.php', 'GuzzleHttp\\MessageFormatter' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/MessageFormatter.php',
'GuzzleHttp\\MessageFormatterInterface' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/MessageFormatterInterface.php', 'GuzzleHttp\\MessageFormatterInterface' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/MessageFormatterInterface.php',
'GuzzleHttp\\Middleware' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Middleware.php', 'GuzzleHttp\\Middleware' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Middleware.php',
'GuzzleHttp\\Multiplexing' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Multiplexing.php',
'GuzzleHttp\\Pool' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Pool.php', 'GuzzleHttp\\Pool' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Pool.php',
'GuzzleHttp\\PrepareBodyMiddleware' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/PrepareBodyMiddleware.php', 'GuzzleHttp\\PrepareBodyMiddleware' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/PrepareBodyMiddleware.php',
'GuzzleHttp\\Promise\\AggregateException' => __DIR__ . '/..' . '/guzzlehttp/promises/src/AggregateException.php', 'GuzzleHttp\\Promise\\AggregateException' => __DIR__ . '/..' . '/guzzlehttp/promises/src/AggregateException.php',
@ -1503,6 +1507,7 @@ class ComposerStaticInite60858b25bb9b11d51011ff69c492447
'iDrivee2Media\\Plugin' => __DIR__ . '/../..' . '/includes/class-plugin.php', 'iDrivee2Media\\Plugin' => __DIR__ . '/../..' . '/includes/class-plugin.php',
'iDrivee2Media\\Rate_Limiter' => __DIR__ . '/../..' . '/includes/class-rate-limiter.php', 'iDrivee2Media\\Rate_Limiter' => __DIR__ . '/../..' . '/includes/class-rate-limiter.php',
'iDrivee2Media\\S3_Client_Factory' => __DIR__ . '/../..' . '/includes/class-s3-client-factory.php', 'iDrivee2Media\\S3_Client_Factory' => __DIR__ . '/../..' . '/includes/class-s3-client-factory.php',
'iDrivee2Media\\Subsize_Filter' => __DIR__ . '/../..' . '/includes/class-subsize-filter.php',
'iDrivee2Media\\URL_Rewriter' => __DIR__ . '/../..' . '/includes/class-url-rewriter.php', 'iDrivee2Media\\URL_Rewriter' => __DIR__ . '/../..' . '/includes/class-url-rewriter.php',
); );

View file

@ -159,27 +159,27 @@
}, },
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.11.0", "version": "7.15.0",
"version_normalized": "7.11.0.0", "version_normalized": "7.15.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/guzzle.git", "url": "https://github.com/guzzle/guzzle.git",
"reference": "c987f8ce84b8434fa430795eca0f3430663da72b" "reference": "90bd104afeb0fcc2190c9eb6fd8a441447e4b30d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b", "url": "https://api.github.com/repos/guzzle/guzzle/zipball/90bd104afeb0fcc2190c9eb6fd8a441447e4b30d",
"reference": "c987f8ce84b8434fa430795eca0f3430663da72b", "reference": "90bd104afeb0fcc2190c9eb6fd8a441447e4b30d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/promises": "^2.5", "guzzlehttp/promises": "^2.5.1",
"guzzlehttp/psr7": "^2.11", "guzzlehttp/psr7": "^2.13",
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0", "psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.24" "symfony/polyfill-php80": "^1.25"
}, },
"provide": { "provide": {
"psr/http-client-implementation": "1.0" "psr/http-client-implementation": "1.0"
@ -187,8 +187,8 @@
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*", "ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2", "guzzle/client-integration-tests": "3.0.3",
"guzzlehttp/test-server": "^0.4", "guzzlehttp/test-server": "^0.7",
"php-http/message-factory": "^1.1", "php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34", "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0" "psr/log": "^1.1 || ^2.0 || ^3.0"
@ -198,7 +198,7 @@
"ext-intl": "Required for Internationalized Domain Name (IDN) support", "ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware" "psr/log": "Required for using the Log middleware"
}, },
"time": "2026-06-02T12:40:51+00:00", "time": "2026-07-17T12:26:48+00:00",
"type": "library", "type": "library",
"extra": { "extra": {
"bamarni-bin": { "bamarni-bin": {
@ -270,7 +270,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/guzzle/issues", "issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.11.0" "source": "https://github.com/guzzle/guzzle/tree/7.15.0"
}, },
"funding": [ "funding": [
{ {
@ -290,17 +290,17 @@
}, },
{ {
"name": "guzzlehttp/promises", "name": "guzzlehttp/promises",
"version": "2.5.0", "version": "2.5.1",
"version_normalized": "2.5.0.0", "version_normalized": "2.5.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/promises.git", "url": "https://github.com/guzzle/promises.git",
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e" "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29",
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -311,7 +311,7 @@
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.52 || ^9.6.34" "phpunit/phpunit": "^8.5.52 || ^9.6.34"
}, },
"time": "2026-06-02T12:23:43+00:00", "time": "2026-07-08T15:48:39+00:00",
"type": "library", "type": "library",
"extra": { "extra": {
"bamarni-bin": { "bamarni-bin": {
@ -357,7 +357,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/promises/issues", "issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.5.0" "source": "https://github.com/guzzle/promises/tree/2.5.1"
}, },
"funding": [ "funding": [
{ {
@ -377,17 +377,17 @@
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.11.0", "version": "2.13.0",
"version_normalized": "2.11.0.0", "version_normalized": "2.13.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/psr7.git", "url": "https://github.com/guzzle/psr7.git",
"reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4",
"reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -396,7 +396,7 @@
"psr/http-message": "^1.1 || ^2.0", "psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0", "ralouphie/getallheaders": "^3.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.24" "symfony/polyfill-php80": "^1.25"
}, },
"provide": { "provide": {
"psr/http-factory-implementation": "1.0", "psr/http-factory-implementation": "1.0",
@ -411,7 +411,7 @@
"suggest": { "suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
}, },
"time": "2026-06-02T12:30:48+00:00", "time": "2026-07-16T22:23:49+00:00",
"type": "library", "type": "library",
"extra": { "extra": {
"bamarni-bin": { "bamarni-bin": {
@ -479,7 +479,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/psr7/issues", "issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.11.0" "source": "https://github.com/guzzle/psr7/tree/2.13.0"
}, },
"funding": [ "funding": [
{ {
@ -499,17 +499,17 @@
}, },
{ {
"name": "mtdowling/jmespath.php", "name": "mtdowling/jmespath.php",
"version": "2.8.0", "version": "2.9.2",
"version_normalized": "2.8.0.0", "version_normalized": "2.9.2.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/jmespath/jmespath.php.git", "url": "https://github.com/jmespath/jmespath.php.git",
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -518,16 +518,16 @@
}, },
"require-dev": { "require-dev": {
"composer/xdebug-handler": "^3.0.3", "composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.33" "phpunit/phpunit": "^8.5.52"
}, },
"time": "2024-09-04T18:46:31+00:00", "time": "2026-07-06T18:56:19+00:00",
"bin": [ "bin": [
"bin/jp.php" "bin/jp.php"
], ],
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "2.8-dev" "dev-master": "2.9-dev"
} }
}, },
"installation-source": "dist", "installation-source": "dist",
@ -562,7 +562,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/jmespath/jmespath.php/issues", "issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
}, },
"install-path": "../mtdowling/jmespath.php" "install-path": "../mtdowling/jmespath.php"
}, },
@ -784,23 +784,23 @@
}, },
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
"version": "v3.7.0", "version": "v3.7.1",
"version_normalized": "3.7.0.0", "version_normalized": "3.7.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git", "url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.1" "php": ">=8.1"
}, },
"time": "2026-04-13T15:52:40+00:00", "time": "2026-06-05T06:23:12+00:00",
"type": "library", "type": "library",
"extra": { "extra": {
"thanks": { "thanks": {
@ -834,7 +834,7 @@
"description": "A generic function and convention to trigger deprecation notices", "description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
}, },
"funding": [ "funding": [
{ {
@ -1018,17 +1018,17 @@
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
"version": "v1.38.1", "version": "v1.38.2",
"version_normalized": "1.38.1.0", "version_normalized": "1.38.2.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git", "url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1041,7 +1041,7 @@
"suggest": { "suggest": {
"ext-mbstring": "For best performance" "ext-mbstring": "For best performance"
}, },
"time": "2026-05-26T12:51:13+00:00", "time": "2026-05-27T06:59:30+00:00",
"type": "library", "type": "library",
"extra": { "extra": {
"thanks": { "thanks": {
@ -1082,7 +1082,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
}, },
"funding": [ "funding": [
{ {

View file

@ -29,36 +29,36 @@
'dev_requirement' => false, 'dev_requirement' => false,
), ),
'guzzlehttp/guzzle' => array( 'guzzlehttp/guzzle' => array(
'pretty_version' => '7.11.0', 'pretty_version' => '7.15.0',
'version' => '7.11.0.0', 'version' => '7.15.0.0',
'reference' => 'c987f8ce84b8434fa430795eca0f3430663da72b', 'reference' => '90bd104afeb0fcc2190c9eb6fd8a441447e4b30d',
'type' => 'library', 'type' => 'library',
'install_path' => __DIR__ . '/../guzzlehttp/guzzle', 'install_path' => __DIR__ . '/../guzzlehttp/guzzle',
'aliases' => array(), 'aliases' => array(),
'dev_requirement' => false, 'dev_requirement' => false,
), ),
'guzzlehttp/promises' => array( 'guzzlehttp/promises' => array(
'pretty_version' => '2.5.0', 'pretty_version' => '2.5.1',
'version' => '2.5.0.0', 'version' => '2.5.1.0',
'reference' => '4360e982f87f5f258bf872d094647791db2f4c8e', 'reference' => '9ad1e4fc607446a055b95870c7f668e93b5cff29',
'type' => 'library', 'type' => 'library',
'install_path' => __DIR__ . '/../guzzlehttp/promises', 'install_path' => __DIR__ . '/../guzzlehttp/promises',
'aliases' => array(), 'aliases' => array(),
'dev_requirement' => false, 'dev_requirement' => false,
), ),
'guzzlehttp/psr7' => array( 'guzzlehttp/psr7' => array(
'pretty_version' => '2.11.0', 'pretty_version' => '2.13.0',
'version' => '2.11.0.0', 'version' => '2.13.0.0',
'reference' => 'bbb5e61349fa5cb822b3e87842b951088b76b81f', 'reference' => 'dad89620b7a6edb60c15858442eb2e408b45d8f4',
'type' => 'library', 'type' => 'library',
'install_path' => __DIR__ . '/../guzzlehttp/psr7', 'install_path' => __DIR__ . '/../guzzlehttp/psr7',
'aliases' => array(), 'aliases' => array(),
'dev_requirement' => false, 'dev_requirement' => false,
), ),
'mtdowling/jmespath.php' => array( 'mtdowling/jmespath.php' => array(
'pretty_version' => '2.8.0', 'pretty_version' => '2.9.2',
'version' => '2.8.0.0', 'version' => '2.9.2.0',
'reference' => 'a2a865e05d5f420b50cc2f85bb78d565db12a6bc', 'reference' => '2157c5e50e813ec6a96c1eed3be7f64a20fb32a8',
'type' => 'library', 'type' => 'library',
'install_path' => __DIR__ . '/../mtdowling/jmespath.php', 'install_path' => __DIR__ . '/../mtdowling/jmespath.php',
'aliases' => array(), 'aliases' => array(),
@ -128,9 +128,9 @@
'dev_requirement' => false, 'dev_requirement' => false,
), ),
'symfony/deprecation-contracts' => array( 'symfony/deprecation-contracts' => array(
'pretty_version' => 'v3.7.0', 'pretty_version' => 'v3.7.1',
'version' => '3.7.0.0', 'version' => '3.7.1.0',
'reference' => '50f59d1f3ca46d41ac911f97a78626b6756af35b', 'reference' => 'f3202fa1b5097b0af062dc978b32ecf63404e31d',
'type' => 'library', 'type' => 'library',
'install_path' => __DIR__ . '/../symfony/deprecation-contracts', 'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
'aliases' => array(), 'aliases' => array(),
@ -155,9 +155,9 @@
'dev_requirement' => false, 'dev_requirement' => false,
), ),
'symfony/polyfill-mbstring' => array( 'symfony/polyfill-mbstring' => array(
'pretty_version' => 'v1.38.1', 'pretty_version' => 'v1.38.2',
'version' => '1.38.1.0', 'version' => '1.38.2.0',
'reference' => '14c5439eec4ccff081ac14eca2dc57feb2a66d92', 'reference' => 'd3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6',
'type' => 'library', 'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-mbstring', 'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
'aliases' => array(), 'aliases' => array(),

File diff suppressed because it is too large Load diff

View file

@ -1,94 +0,0 @@
![Guzzle](.github/logo.png?raw=true)
# Guzzle, PHP HTTP client
[![Latest Version](https://img.shields.io/github/release/guzzle/guzzle.svg?style=flat-square)](https://github.com/guzzle/guzzle/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/guzzle/guzzle/ci.yml?label=ci%20build&style=flat-square)](https://github.com/guzzle/guzzle/actions?query=workflow%3ACI)
[![Total Downloads](https://img.shields.io/packagist/dt/guzzlehttp/guzzle.svg?style=flat-square)](https://packagist.org/packages/guzzlehttp/guzzle)
Guzzle is a PHP HTTP client that makes it easy to send HTTP requests and
trivial to integrate with web services.
- Simple interface for building query strings, POST requests, streaming large
uploads, streaming large downloads, using HTTP cookies, uploading JSON data,
etc...
- Can send both synchronous and asynchronous requests using the same interface.
- Uses PSR-7 interfaces for requests, responses, and streams. This allows you
to utilize other PSR-7 compatible libraries with Guzzle.
- Supports PSR-18 allowing interoperability between other PSR-18 HTTP Clients.
- Abstracts away the underlying HTTP transport, allowing you to write
environment and transport agnostic code; i.e., no hard dependency on cURL,
PHP streams, sockets, or non-blocking event loops.
- Middleware system allows you to augment and compose client behavior.
```php
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', 'https://api.github.com/repos/guzzle/guzzle');
echo $response->getStatusCode(); // 200
echo $response->getHeaderLine('content-type'); // 'application/json; charset=utf8'
echo $response->getBody(); // '{"id": 1420053, "name": "guzzle", ...}'
// Send an asynchronous request.
$request = new \GuzzleHttp\Psr7\Request('GET', 'http://httpbin.org');
$promise = $client->sendAsync($request)->then(function ($response) {
echo 'I completed! ' . $response->getBody();
});
$promise->wait();
```
## Help and docs
We use GitHub issues only to discuss bugs and new features. For support please refer to:
- [Documentation](docs/index.md)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/guzzle)
- [#guzzle](https://app.slack.com/client/T0D2S9JCT/CE6UAAKL4) channel on [PHP-HTTP Slack](https://slack.httplug.io/)
- [Gitter](https://gitter.im/guzzle/guzzle)
## Installing Guzzle
The recommended way to install Guzzle is through
[Composer](https://getcomposer.org/).
```bash
composer require guzzlehttp/guzzle
```
## Version Guidance
| Version | Status | Packagist | Namespace | Repo | Docs | PSR-7 | PHP Version |
|---------|---------------------|---------------------|--------------|---------------------|---------------------|-------|--------------|
| 3.x | EOL (2016-10-31) | `guzzle/guzzle` | `Guzzle` | [v3][guzzle-3-repo] | [v3][guzzle-3-docs] | No | >=5.3.3,<7.0 |
| 4.x | EOL (2016-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v4][guzzle-4-repo] | N/A | No | >=5.4,<7.0 |
| 5.x | EOL (2019-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v5][guzzle-5-repo] | [v5][guzzle-5-docs] | No | >=5.4,<7.4 |
| 6.x | EOL (2023-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v6][guzzle-6-repo] | [v6][guzzle-6-docs] | Yes | >=5.5,<8.0 |
| 7.x | Latest | `guzzlehttp/guzzle` | `GuzzleHttp` | [v7][guzzle-7-repo] | [v7][guzzle-7-docs] | Yes | >=7.2.5,<8.6 |
[guzzle-3-repo]: https://github.com/guzzle/guzzle3
[guzzle-4-repo]: https://github.com/guzzle/guzzle/tree/4.x
[guzzle-5-repo]: https://github.com/guzzle/guzzle/tree/5.3
[guzzle-6-repo]: https://github.com/guzzle/guzzle/tree/6.5
[guzzle-7-repo]: https://github.com/guzzle/guzzle/tree/7.11
[guzzle-3-docs]: https://github.com/guzzle/guzzle3/tree/master/docs
[guzzle-5-docs]: https://github.com/guzzle/guzzle/tree/5.3/docs
[guzzle-6-docs]: https://github.com/guzzle/guzzle/tree/6.5/docs
[guzzle-7-docs]: https://github.com/guzzle/guzzle/blob/7.11/docs/index.md
## Security
If you discover a security vulnerability within this package, please send an email to security@tidelift.com. All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced. Please see [Security Policy](https://github.com/guzzle/guzzle/security/policy) for more information.
## License
Guzzle is made available under the MIT License (MIT). Please see [License File](LICENSE) for more information.
## For Enterprise
Available as part of the Tidelift Subscription
The maintainers of Guzzle and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-guzzle?utm_source=packagist-guzzlehttp-guzzle&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
{
"name": "guzzle",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -3,9 +3,11 @@
namespace GuzzleHttp; namespace GuzzleHttp;
use GuzzleHttp\Cookie\CookieJar; use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\InvalidArgumentException; use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Handler\CurlShareHandleState; use GuzzleHttp\Handler\CurlShareHandleState;
use GuzzleHttp\Handler\CurlVersion;
use GuzzleHttp\Promise as P; use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
@ -52,6 +54,20 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
* into relative URIs. Can be a string or instance of UriInterface. * into relative URIs. Can be a string or instance of UriInterface.
* - transport_sharing: (string|null) Transport sharing mode for the * - transport_sharing: (string|null) Transport sharing mode for the
* default handler. Accepts TransportSharing::* or null. Defaults to null. * default handler. Accepts TransportSharing::* or null. Defaults to null.
* - max_host_connections: (int|null) Maximum concurrent connections per
* host, applied by the default CurlMultiHandler. The default stream
* fallback receives the cap as a marker only: it rejects enabled
* response streaming ("stream" => true) and does not limit overlapping
* buffered calls.
* - max_total_connections: (int|null) Maximum concurrent connections
* overall, applied by the default CurlMultiHandler. The default stream
* fallback receives the cap as a marker only: it rejects enabled
* response streaming ("stream" => true) and does not limit overlapping
* buffered calls.
* - multiplex: (string|null) Multiplexing::NONE to disable multiplexing on
* the default CurlMultiHandler; the value also becomes the default
* "multiplex" request option. Other Multiplexing::* values act as the
* default request option only.
* - **: any request option * - **: any request option
* *
* @param array $config Client configuration settings. * @param array $config Client configuration settings.
@ -60,16 +76,41 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
*/ */
public function __construct(array $config = []) public function __construct(array $config = [])
{ {
$handlerOptions = [];
foreach (['max_host_connections', 'max_total_connections'] as $capOption) {
if (\array_key_exists($capOption, $config)) {
if ($config[$capOption] !== null) {
$handlerOptions[$capOption] = $config[$capOption];
}
unset($config[$capOption]);
}
}
// Deliberately not unset: the value also becomes the default
// "multiplex" request option, which the configured handler accepts.
$handlerMultiplex = ($config['multiplex'] ?? null) === Multiplexing::NONE;
$transportSharing = \array_key_exists('transport_sharing', $config) ? $config['transport_sharing'] : null; $transportSharing = \array_key_exists('transport_sharing', $config) ? $config['transport_sharing'] : null;
$transportSharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing'); $transportSharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing');
unset($config['transport_sharing']); unset($config['transport_sharing']);
if (!isset($config['handler'])) { if (!isset($config['handler'])) {
$config['handler'] = $transportSharingMode === TransportSharing::NONE if ($transportSharingMode !== TransportSharing::NONE) {
$handlerOptions['transport_sharing'] = $transportSharingMode;
}
if ($handlerMultiplex) {
$handlerOptions['multiplex'] = Multiplexing::NONE;
}
$config['handler'] = $handlerOptions === []
? HandlerStack::create() ? HandlerStack::create()
: HandlerStack::create(Utils::chooseHandler(['transport_sharing' => $transportSharingMode])); : HandlerStack::create(Utils::chooseHandler($handlerOptions));
} elseif (!\is_callable($config['handler'])) { } elseif (!\is_callable($config['handler'])) {
throw new InvalidArgumentException('handler must be a callable'); throw new InvalidArgumentException('handler must be a callable');
} elseif ($handlerOptions !== []) {
throw new InvalidArgumentException('The "max_host_connections" and "max_total_connections" client options require Guzzle to create the default handler. Configure the options on the CurlMultiHandler constructor to apply numeric connection caps, or on the StreamHandler constructor to reject enabled response streaming, when providing a custom handler.');
} elseif ($transportSharingMode === TransportSharing::HANDLER_REQUIRE) { } elseif ($transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler or CurlMultiHandler when providing a custom cURL handler.'); throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler or CurlMultiHandler when providing a custom cURL handler.');
} }
@ -92,6 +133,8 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
*/ */
public function __call($method, $args) public function __call($method, $args)
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s::%s() is deprecated and will be removed in 8.0.', __CLASS__, __FUNCTION__);
if (\count($args) < 1) { if (\count($args) < 1) {
throw new InvalidArgumentException('Magic request methods require a URI and optional options array'); throw new InvalidArgumentException('Magic request methods require a URI and optional options array');
} }
@ -101,7 +144,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
$isAsync = \substr($method, -5) === 'Async'; $isAsync = \substr($method, -5) === 'Async';
$method = $isAsync ? \substr($method, 0, -5) : $method; $method = $isAsync ? \substr($method, 0, -5) : $method;
$method = \strtoupper($method); $method = Psr7\Utils::asciiToUpper($method);
return $isAsync return $isAsync
? $this->requestAsync($method, $uri, $opts) ? $this->requestAsync($method, $uri, $opts)
@ -168,7 +211,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
*/ */
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
{ {
$normalizedMethod = \strtoupper($method); $normalizedMethod = Psr7\Utils::asciiToUpper($method);
if ($method !== $normalizedMethod) { if ($method !== $normalizedMethod) {
\trigger_deprecation( \trigger_deprecation(
'guzzlehttp/guzzle', 'guzzlehttp/guzzle',
@ -192,6 +235,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
if (\is_array($body)) { if (\is_array($body)) {
throw $this->invalidBody(); throw $this->invalidBody();
} }
$body = self::createBodyStream($body);
$request = new Psr7\Request($method, $uri, $headers, $body, $version); $request = new Psr7\Request($method, $uri, $headers, $body, $version);
// Remove the option so that they are not doubly-applied. // Remove the option so that they are not doubly-applied.
unset($options['headers'], $options['body'], $options['version']); unset($options['headers'], $options['body'], $options['version']);
@ -214,7 +258,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
*/ */
public function request(string $method, $uri = '', array $options = []): ResponseInterface public function request(string $method, $uri = '', array $options = []): ResponseInterface
{ {
$normalizedMethod = \strtoupper($method); $normalizedMethod = Psr7\Utils::asciiToUpper($method);
if ($method !== $normalizedMethod) { if ($method !== $normalizedMethod) {
\trigger_deprecation( \trigger_deprecation(
'guzzlehttp/guzzle', 'guzzlehttp/guzzle',
@ -258,7 +302,11 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
$uri = Utils::idnUriConvert($uri, $idnOptions); $uri = Utils::idnUriConvert($uri, $idnOptions);
} }
return $uri->getScheme() === '' && $uri->getHost() !== '' ? $uri->withScheme('http') : $uri; if ($uri->getScheme() === '' && $uri->getHost() !== '') {
$uri = $uri->withScheme('http');
}
return $uri;
} }
/** /**
@ -307,7 +355,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
// Add the User-Agent header if one was not already set. // Add the User-Agent header if one was not already set.
$hasUserAgent = false; $hasUserAgent = false;
foreach (\array_keys($this->config['headers']) as $name) { foreach (\array_keys($this->config['headers']) as $name) {
if (\strtolower((string) $name) === 'user-agent') { if (Psr7\Utils::asciiToLower((string) $name) === 'user-agent') {
$hasUserAgent = true; $hasUserAgent = true;
break; break;
} }
@ -331,6 +379,8 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
*/ */
private function prepareDefaults(array $options): array private function prepareDefaults(array $options): array
{ {
self::warnAboutRequestLevelHandler($options);
$defaults = $this->config; $defaults = $this->config;
if (!empty($defaults['headers'])) { if (!empty($defaults['headers'])) {
@ -363,7 +413,178 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
self::warnAboutInvalidRequestOptionTypes($result); self::warnAboutInvalidRequestOptionTypes($result);
return $result; return self::normalizeDeprecatedRequestOptionValues($result);
}
/**
* Normalize values that guzzlehttp/guzzle 8.0 rejects only after the
* corresponding 7.x deprecation has already been emitted.
*
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private static function normalizeDeprecatedRequestOptionValues(array $options): array
{
self::normalizeDeprecatedAuthOptionValues($options);
self::normalizeDeprecatedTlsFileOptionValues($options, 'cert');
self::normalizeDeprecatedTlsFileOptionValues($options, 'ssl_key');
self::normalizeDeprecatedStringOptionValues($options);
self::normalizeDeprecatedNumericOptionValues($options);
self::normalizeDeprecatedIntegerOptionValues($options);
return $options;
}
/**
* @param mixed $value
*/
private static function canStringifyDeprecatedValue($value): bool
{
return $value === null
|| \is_scalar($value)
|| (\is_object($value) && \method_exists($value, '__toString'));
}
/**
* @param mixed $value
*/
private static function stringifyDeprecatedValue($value): string
{
if (\is_float($value) && !\is_finite($value)) {
return \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
if ($value === null) {
return '';
}
if (\is_scalar($value)) {
return (string) $value;
}
if (\is_object($value) && \method_exists($value, '__toString')) {
return $value->__toString();
}
throw new \LogicException('Value is not stringable.');
}
/**
* @param array<string, mixed> $options
*/
private static function normalizeDeprecatedAuthOptionValues(array &$options): void
{
if (!isset($options['auth']) || !\is_array($options['auth']) || $options['auth'] === []) {
return;
}
foreach ([0, 1] as $index) {
if (
\array_key_exists($index, $options['auth'])
&& !\is_string($options['auth'][$index])
&& self::canStringifyDeprecatedValue($options['auth'][$index])
) {
$options['auth'][$index] = self::stringifyDeprecatedValue($options['auth'][$index]);
}
}
if (
\array_key_exists(2, $options['auth'])
&& $options['auth'][2] !== null
&& !\is_string($options['auth'][2])
&& self::canStringifyDeprecatedValue($options['auth'][2])
) {
$options['auth'][2] = self::stringifyDeprecatedValue($options['auth'][2]);
}
}
/**
* @param array<string, mixed> $options
*/
private static function normalizeDeprecatedTlsFileOptionValues(array &$options, string $option): void
{
if (!isset($options[$option]) || !\is_array($options[$option])) {
return;
}
foreach ([0, 1] as $index) {
if (
\array_key_exists($index, $options[$option])
&& $options[$option][$index] !== null
&& !\is_string($options[$option][$index])
&& self::canStringifyDeprecatedValue($options[$option][$index])
) {
$options[$option][$index] = self::stringifyDeprecatedValue($options[$option][$index]);
}
}
}
/**
* @param array<string, mixed> $options
*/
private static function normalizeDeprecatedStringOptionValues(array &$options): void
{
foreach (['cert_type', 'force_ip_resolve', 'ssl_key_type'] as $option) {
if (
\array_key_exists($option, $options)
&& !\is_string($options[$option])
&& self::canStringifyDeprecatedValue($options[$option])
) {
$options[$option] = self::stringifyDeprecatedValue($options[$option]);
}
}
}
/**
* @param array<string, mixed> $options
*/
private static function normalizeDeprecatedNumericOptionValues(array &$options): void
{
foreach (['connect_timeout', 'delay', 'read_timeout', 'timeout'] as $option) {
if (
\array_key_exists($option, $options)
&& \is_string($options[$option])
&& \is_numeric($options[$option])
) {
$options[$option] = $options[$option] + 0;
}
}
}
/**
* @param array<string, mixed> $options
*/
private static function normalizeDeprecatedIntegerOptionValues(array &$options): void
{
foreach (['crypto_method', 'crypto_method_max', 'retries'] as $option) {
if (!\array_key_exists($option, $options)) {
continue;
}
if (\is_string($options[$option]) && \preg_match('/^-?\d+$/D', $options[$option]) === 1) {
$options[$option] = (int) $options[$option];
} elseif (
\is_float($options[$option])
&& \is_finite($options[$option])
&& $options[$option] === (float) (int) $options[$option]
) {
$options[$option] = (int) $options[$option];
}
}
}
private static function warnAboutRequestLevelHandler(array $options): void
{
if (!\array_key_exists('handler', $options)) {
return;
}
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.12',
'Passing the "handler" request option is deprecated; guzzlehttp/guzzle 8.0 will ignore request-level handlers. Configure the handler when creating the Client, or use a separate Client instance for requests that need a different handler.'
);
} }
private static function warnAboutInvalidRequestOptionTypes(array $options): void private static function warnAboutInvalidRequestOptionTypes(array $options): void
@ -374,6 +595,8 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
if (isset($options['allow_redirects']) && \is_array($options['allow_redirects'])) { if (isset($options['allow_redirects']) && \is_array($options['allow_redirects'])) {
self::warnAboutInvalidAllowRedirectsOptionTypes($options['allow_redirects']); self::warnAboutInvalidAllowRedirectsOptionTypes($options['allow_redirects']);
} elseif (isset($options['allow_redirects']) && !\is_bool($options['allow_redirects'])) {
self::warnInvalidRequestOptionType('allow_redirects', 'bool|array', $options['allow_redirects'], '7.13');
} }
if (isset($options['auth'])) { if (isset($options['auth'])) {
@ -388,9 +611,16 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
self::warnIfPresentAndNotString($options, 'cert_type'); self::warnIfPresentAndNotString($options, 'cert_type');
self::warnIfPresentAndNotNumber($options, 'connect_timeout'); self::warnIfPresentAndNotNumber($options, 'connect_timeout');
self::warnIfPresentAndNotInt($options, 'crypto_method'); self::warnIfPresentAndNotInt($options, 'crypto_method');
self::warnIfPresentAndNotInt($options, 'crypto_method_max', null, '7.13');
self::warnIfPresentAndNotBoolOrResource($options, 'debug'); self::warnIfPresentAndNotBoolOrResource($options, 'debug');
self::warnIfPresentAndNotBoolOrString($options, 'decode_content'); self::warnIfPresentAndNotBoolOrString($options, 'decode_content');
self::warnIfPresentAndNotNumber($options, 'delay'); self::warnIfPresentAndNotNumber($options, 'delay');
if (isset($options['delay']) && \is_numeric($options['delay'])) {
$delay = (float) $options['delay'];
if (!\is_finite($delay) || $delay < 0.0) {
self::warnInvalidRequestOptionType('delay', 'finite int|float greater than or equal to 0', $options['delay'], '7.13');
}
}
self::warnIfPresentAndNotBoolOrInt($options, 'expect'); self::warnIfPresentAndNotBoolOrInt($options, 'expect');
if (isset($options['form_params'])) { if (isset($options['form_params'])) {
@ -401,6 +631,15 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
self::warnInvalidRequestOptionType('force_ip_resolve', 'string', $options['force_ip_resolve']); self::warnInvalidRequestOptionType('force_ip_resolve', 'string', $options['force_ip_resolve']);
} }
if (
isset($options['force_ip_resolve'])
&& \is_string($options['force_ip_resolve'])
&& $options['force_ip_resolve'] !== 'v4'
&& $options['force_ip_resolve'] !== 'v6'
) {
self::warnInvalidRequestOptionType('force_ip_resolve', '"v4"|"v6"', $options['force_ip_resolve'], '7.13');
}
if (isset($options['headers'])) { if (isset($options['headers'])) {
self::warnAboutInvalidHeaderOptionTypes($options['headers']); self::warnAboutInvalidHeaderOptionTypes($options['headers']);
} }
@ -413,8 +652,10 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
self::warnIfPresentAndNotCallable($options, 'on_headers'); self::warnIfPresentAndNotCallable($options, 'on_headers');
self::warnIfPresentAndNotCallable($options, 'on_stats'); self::warnIfPresentAndNotCallable($options, 'on_stats');
self::warnIfPresentAndNotCallable($options, 'on_trailers', null, '7.14');
self::warnIfPresentAndNotCallable($options, 'progress'); self::warnIfPresentAndNotCallable($options, 'progress');
self::warnIfPresentAndNotStringArray($options, 'protocols', true); self::warnIfPresentAndNotStringArray($options, 'protocols', true);
self::warnAboutInvalidProtocolValues($options, 'protocols');
self::warnAboutInvalidProxyOptionTypes($options); self::warnAboutInvalidProxyOptionTypes($options);
self::warnIfPresentAndNotNumber($options, 'read_timeout'); self::warnIfPresentAndNotNumber($options, 'read_timeout');
@ -437,6 +678,15 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
if (isset($options['cookies']) && $options['cookies'] === true) { if (isset($options['cookies']) && $options['cookies'] === true) {
self::warnInvalidRequestOptionType('cookies', 'false|CookieJarInterface', $options['cookies']); self::warnInvalidRequestOptionType('cookies', 'false|CookieJarInterface', $options['cookies']);
} }
if (
isset($options['cookies'])
&& $options['cookies'] !== false
&& $options['cookies'] !== true
&& !($options['cookies'] instanceof CookieJarInterface)
) {
self::warnInvalidRequestOptionType('cookies', 'false|CookieJarInterface', $options['cookies'], '7.13');
}
} }
private static function warnAboutInvalidAllowRedirectsOptionTypes(array $allowRedirects): void private static function warnAboutInvalidAllowRedirectsOptionTypes(array $allowRedirects): void
@ -445,6 +695,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
self::warnIfPresentAndNotBool($allowRedirects, 'strict', 'allow_redirects.strict'); self::warnIfPresentAndNotBool($allowRedirects, 'strict', 'allow_redirects.strict');
self::warnIfPresentAndNotBool($allowRedirects, 'referer', 'allow_redirects.referer'); self::warnIfPresentAndNotBool($allowRedirects, 'referer', 'allow_redirects.referer');
self::warnIfPresentAndNotStringArray($allowRedirects, 'protocols', true, 'allow_redirects.protocols'); self::warnIfPresentAndNotStringArray($allowRedirects, 'protocols', true, 'allow_redirects.protocols');
self::warnAboutInvalidProtocolValues($allowRedirects, 'protocols', 'allow_redirects.protocols');
self::warnIfPresentAndNotCallable($allowRedirects, 'on_redirect', 'allow_redirects.on_redirect'); self::warnIfPresentAndNotCallable($allowRedirects, 'on_redirect', 'allow_redirects.on_redirect');
self::warnIfPresentAndNotBool($allowRedirects, 'track_redirects', 'allow_redirects.track_redirects'); self::warnIfPresentAndNotBool($allowRedirects, 'track_redirects', 'allow_redirects.track_redirects');
} }
@ -700,17 +951,25 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
} }
} }
private static function warnIfPresentAndNotCallable(array $options, string $option, ?string $path = null): void private static function warnIfPresentAndNotCallable(
{ array $options,
string $option,
?string $path = null,
string $since = '7.11'
): void {
if (\array_key_exists($option, $options) && !\is_callable($options[$option])) { if (\array_key_exists($option, $options) && !\is_callable($options[$option])) {
self::warnInvalidRequestOptionType($path ?? $option, 'callable', $options[$option]); self::warnInvalidRequestOptionType($path ?? $option, 'callable', $options[$option], $since);
} }
} }
private static function warnIfPresentAndNotInt(array $options, string $option, ?string $path = null): void private static function warnIfPresentAndNotInt(
{ array $options,
string $option,
?string $path = null,
string $since = '7.11'
): void {
if (\array_key_exists($option, $options) && !\is_int($options[$option])) { if (\array_key_exists($option, $options) && !\is_int($options[$option])) {
self::warnInvalidRequestOptionType($path ?? $option, 'int', $options[$option]); self::warnInvalidRequestOptionType($path ?? $option, 'int', $options[$option], $since);
} }
} }
@ -752,6 +1011,24 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
} }
} }
/**
* @param array<array-key, mixed> $options
*/
private static function warnAboutInvalidProtocolValues(array $options, string $option, ?string $path = null): void
{
if (!isset($options[$option]) || !\is_array($options[$option])) {
return;
}
$path = $path ?? $option;
foreach ($options[$option] as $index => $protocol) {
if (\is_string($protocol) && $protocol !== 'http' && $protocol !== 'https') {
self::warnInvalidRequestOptionType($path.'.'.(string) $index, '"http"|"https"', $protocol, '7.13');
}
}
}
private static function warnIfPresentAndNotStringOrNumber(array $options, string $option): void private static function warnIfPresentAndNotStringOrNumber(array $options, string $option): void
{ {
if ( if (
@ -767,11 +1044,11 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
/** /**
* @param mixed $value * @param mixed $value
*/ */
private static function warnInvalidRequestOptionType(string $option, string $expected, $value): void private static function warnInvalidRequestOptionType(string $option, string $expected, $value, string $since = '7.11'): void
{ {
\trigger_deprecation( \trigger_deprecation(
'guzzlehttp/guzzle', 'guzzlehttp/guzzle',
'7.11', $since,
'Passing %s to request option "%s" is deprecated; guzzlehttp/guzzle 8.0 requires %s.', 'Passing %s to request option "%s" is deprecated; guzzlehttp/guzzle 8.0 requires %s.',
\get_debug_type($value), \get_debug_type($value),
$option, $option,
@ -841,7 +1118,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
.'x-www-form-urlencoded requests, and the multipart ' .'x-www-form-urlencoded requests, and the multipart '
.'option to send multipart/form-data requests.'); .'option to send multipart/form-data requests.');
} }
$options['body'] = \http_build_query($options['form_params'], '', '&'); $options['body'] = \http_build_query(self::normalizeNonFiniteFloats($options['form_params'], 'form_params'), '', '&');
unset($options['form_params']); unset($options['form_params']);
// Ensure that we don't have the header in different case and set the new value. // Ensure that we don't have the header in different case and set the new value.
$options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']); $options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']);
@ -854,16 +1131,20 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
} }
if (isset($options['json'])) { if (isset($options['json'])) {
$options['body'] = Utils::jsonEncode($options['json']); $json = \json_encode($options['json']);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg());
}
/** @var non-empty-string $json */
$options['body'] = $json;
unset($options['json']); unset($options['json']);
// Ensure that we don't have the header in different case and set the new value. // Ensure that we don't have the header in different case and set the new value.
$options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']); $options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']);
$options['_conditional']['Content-Type'] = 'application/json'; $options['_conditional']['Content-Type'] = 'application/json';
} }
if (!empty($options['decode_content']) if (isset($options['decode_content']) && \is_string($options['decode_content'])) {
&& $options['decode_content'] !== true
) {
// Ensure that we don't have the header in different case and set the new value. // Ensure that we don't have the header in different case and set the new value.
$options['_conditional'] = Psr7\Utils::caselessRemove(['Accept-Encoding'], $options['_conditional']); $options['_conditional'] = Psr7\Utils::caselessRemove(['Accept-Encoding'], $options['_conditional']);
$modify['set_headers']['Accept-Encoding'] = (string) $options['decode_content']; $modify['set_headers']['Accept-Encoding'] = (string) $options['decode_content'];
@ -873,13 +1154,13 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
if (\is_array($options['body'])) { if (\is_array($options['body'])) {
throw $this->invalidBody(); throw $this->invalidBody();
} }
$modify['body'] = Psr7\Utils::streamFor($options['body']); $modify['body'] = self::createBodyStream($options['body']);
unset($options['body']); unset($options['body']);
} }
if (!empty($options['auth']) && \is_array($options['auth'])) { if (!empty($options['auth']) && \is_array($options['auth'])) {
$value = $options['auth']; $value = $options['auth'];
$type = isset($value[2]) ? \strtolower($value[2]) : 'basic'; $type = isset($value[2]) ? Psr7\Utils::asciiToLower($value[2]) : 'basic';
switch ($type) { switch ($type) {
case 'basic': case 'basic':
// Ensure that we don't have the header in different case and set the new value. // Ensure that we don't have the header in different case and set the new value.
@ -893,6 +1174,14 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
$options['curl'][\CURLOPT_USERPWD] = "$value[0]:$value[1]"; $options['curl'][\CURLOPT_USERPWD] = "$value[0]:$value[1]";
break; break;
case 'ntlm': case 'ntlm':
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.12',
'Passing "ntlm" as the built-in auth type is deprecated; guzzlehttp/guzzle 8.0 will no longer apply NTLM through the "auth" request option. NTLM is also deprecated by curl/libcurl and may be unavailable in current or future libcurl builds. Avoid NTLM; if you must use it temporarily, configure cURL HTTP authentication options directly with a libcurl build that still supports NTLM.'
);
if (!CurlVersion::supportsNtlm()) {
throw new InvalidArgumentException('NTLM authentication is not available because the installed curl/libcurl build does not provide NTLM support.');
}
$options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_NTLM; $options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_NTLM;
$options['curl'][\CURLOPT_USERPWD] = "$value[0]:$value[1]"; $options['curl'][\CURLOPT_USERPWD] = "$value[0]:$value[1]";
break; break;
@ -902,7 +1191,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
if (isset($options['query'])) { if (isset($options['query'])) {
$value = $options['query']; $value = $options['query'];
if (\is_array($value)) { if (\is_array($value)) {
$value = \http_build_query($value, '', '&', \PHP_QUERY_RFC3986); $value = \http_build_query(self::normalizeNonFiniteFloats($value, 'query'), '', '&', \PHP_QUERY_RFC3986);
} }
if (!\is_string($value)) { if (!\is_string($value)) {
throw new InvalidArgumentException('query must be a string or array'); throw new InvalidArgumentException('query must be a string or array');
@ -970,6 +1259,9 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
foreach ($value as $index => $item) { foreach ($value as $index => $item) {
if ($item === null || (!\is_string($item) && \is_scalar($item))) { if ($item === null || (!\is_string($item) && \is_scalar($item))) {
if (\is_float($item) && !\is_finite($item)) {
$item = \is_nan($item) ? 'NAN' : ($item > 0 ? 'INF' : '-INF');
}
$value[$index] = (string) $item; $value[$index] = (string) $item;
} }
} }
@ -980,6 +1272,9 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
} }
if ($value === null || (!\is_string($value) && \is_scalar($value))) { if ($value === null || (!\is_string($value) && \is_scalar($value))) {
if (\is_float($value) && !\is_finite($value)) {
$value = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
$headers[$name] = (string) $value; $headers[$name] = (string) $value;
} }
} }
@ -987,6 +1282,72 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
return $droppedHeaderNames; return $droppedHeaderNames;
} }
/**
* @param mixed $body
*/
private static function createBodyStream($body): StreamInterface
{
if ($body instanceof StreamInterface) {
return $body;
}
if (\is_resource($body) || $body === null || \is_string($body) || $body instanceof \Iterator) {
return Psr7\Utils::streamFor($body);
}
if (\is_scalar($body)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.12', 'Passing a non-string scalar to the "body" request option is deprecated; guzzlehttp/guzzle 8.0 will reject non-string scalar bodies.');
return Psr7\Utils::streamFor(self::stringifyScalar($body));
}
if (\is_object($body) && \method_exists($body, '__toString')) {
return Psr7\Utils::streamFor((string) $body);
}
if (\is_callable($body)) {
return Psr7\Utils::streamFor($body);
}
throw new InvalidArgumentException(\sprintf(
'Passing %s to request option "body" is invalid; expected resource|string|null|int|float|bool|StreamInterface|callable&object|Iterator|Stringable.',
\get_debug_type($body)
));
}
/**
* @param bool|float|int|string $value
*/
private static function stringifyScalar($value): string
{
// Normalize non-finite floats to dodge PHP 8.5's (string) NAN
// coercion warning while the value is still accepted.
if (\is_float($value) && !\is_finite($value)) {
$value = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
return (string) $value;
}
/**
* Converts non-finite floats in the array to the strings PHP coerces
* them to, as implicit coercion of NAN emits a warning on PHP 8.5.
*/
private static function normalizeNonFiniteFloats(array $values, string $option): array
{
foreach ($values as $key => $value) {
if (\is_array($value)) {
$values[$key] = self::normalizeNonFiniteFloats($value, $option);
} elseif (\is_float($value) && !\is_finite($value)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.12', 'Passing a non-finite float in the "%s" request option is deprecated; guzzlehttp/guzzle 8.0 will reject non-finite floats.', $option);
$values[$key] = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
}
return $values;
}
/** /**
* @param string|int|float $version * @param string|int|float $version
*/ */

View file

@ -2,6 +2,7 @@
namespace GuzzleHttp\Cookie; namespace GuzzleHttp\Cookie;
use GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
@ -88,7 +89,7 @@ class CookieJar implements CookieJarInterface
public function getCookieByName(string $name): ?SetCookie public function getCookieByName(string $name): ?SetCookie
{ {
foreach ($this->cookies as $cookie) { foreach ($this->cookies as $cookie) {
if ($cookie->getName() !== null && \strcasecmp($cookie->getName(), $name) === 0) { if ($cookie->getName() !== null && Psr7\Utils::caselessEquals($cookie->getName(), $name)) {
return $cookie; return $cookie;
} }
} }
@ -109,14 +110,14 @@ class CookieJar implements CookieJarInterface
$this->cookies = []; $this->cookies = [];
return; return;
} elseif (!$path) { } elseif ($path === null) {
$this->cookies = \array_filter( $this->cookies = \array_filter(
$this->cookies, $this->cookies,
static function (SetCookie $cookie) use ($domain): bool { static function (SetCookie $cookie) use ($domain): bool {
return $cookie->getDomain() === null || !$cookie->matchesDomain($domain); return $cookie->getDomain() === null || !$cookie->matchesDomain($domain);
} }
); );
} elseif (!$name) { } elseif ($name === null) {
$this->cookies = \array_filter( $this->cookies = \array_filter(
$this->cookies, $this->cookies,
static function (SetCookie $cookie) use ($path, $domain): bool { static function (SetCookie $cookie) use ($path, $domain): bool {
@ -130,7 +131,7 @@ class CookieJar implements CookieJarInterface
$this->cookies, $this->cookies,
static function (SetCookie $cookie) use ($path, $domain, $name) { static function (SetCookie $cookie) use ($path, $domain, $name) {
return !($cookie->getDomain() !== null return !($cookie->getDomain() !== null
&& $cookie->getName() == $name && $cookie->getName() === $name
&& $cookie->matchesPath($path) && $cookie->matchesPath($path)
&& $cookie->matchesDomain($domain)); && $cookie->matchesDomain($domain));
} }
@ -168,13 +169,22 @@ class CookieJar implements CookieJarInterface
return false; return false;
} }
$maxAge = $cookie->getMaxAge();
if ($maxAge !== null && $maxAge <= 0) {
if ($cookie->getDomain() !== null) {
$this->clear($cookie->getDomain(), $cookie->getPath(), $cookie->getName());
}
return false;
}
// Resolve conflicts with previously set cookies // Resolve conflicts with previously set cookies
foreach ($this->cookies as $i => $c) { foreach ($this->cookies as $i => $c) {
// Two cookies are identical, when their path, and domain are // Two cookies are identical, when their path, and domain are
// identical. // identical.
if ($c->getPath() != $cookie->getPath() if ($c->getPath() !== $cookie->getPath()
|| $c->getDomain() != $cookie->getDomain() || $c->getDomain() !== $cookie->getDomain()
|| $c->getName() != $cookie->getName() || $c->getName() !== $cookie->getName()
) { ) {
continue; continue;
} }
@ -226,7 +236,11 @@ class CookieJar implements CookieJarInterface
if ($cookieHeader = $response->getHeader('Set-Cookie')) { if ($cookieHeader = $response->getHeader('Set-Cookie')) {
foreach ($cookieHeader as $cookie) { foreach ($cookieHeader as $cookie) {
$sc = SetCookie::fromString($cookie); $sc = SetCookie::fromString($cookie);
if (!$sc->getDomain()) { $domain = $sc->getDomain();
if ($domain === null || $domain === '') {
$sc->setDomain($request->getUri()->getHost());
} elseif (\substr($domain, -1) === '.' && '' !== \trim($domain, '.')) {
// Keep pure-dot domains rejected by the dot-only fix.
$sc->setDomain($request->getUri()->getHost()); $sc->setDomain($request->getUri()->getHost());
} }
if (0 !== \strpos($sc->getPath(), '/')) { if (0 !== \strpos($sc->getPath(), '/')) {

View file

@ -2,7 +2,7 @@
namespace GuzzleHttp\Cookie; namespace GuzzleHttp\Cookie;
use GuzzleHttp\Utils; use GuzzleHttp\Exception\InvalidArgumentException;
/** /**
* Persists non-session cookies using a JSON formatted file * Persists non-session cookies using a JSON formatted file
@ -64,7 +64,12 @@ class FileCookieJar extends CookieJar
} }
} }
$jsonStr = Utils::jsonEncode($json); $jsonStr = \json_encode($json);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg());
}
/** @var non-empty-string $jsonStr */
if (false === \file_put_contents($filename, $jsonStr, \LOCK_EX)) { if (false === \file_put_contents($filename, $jsonStr, \LOCK_EX)) {
throw new \RuntimeException("Unable to save file {$filename}"); throw new \RuntimeException("Unable to save file {$filename}");
} }
@ -89,7 +94,11 @@ class FileCookieJar extends CookieJar
return; return;
} }
$data = Utils::jsonDecode($json, true); $data = \json_decode($json, true);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new InvalidArgumentException('json_decode error: '.\json_last_error_msg());
}
if (\is_array($data)) { if (\is_array($data)) {
foreach ($data as $cookie) { foreach ($data as $cookie) {
$this->setCookie(new SetCookie($cookie)); $this->setCookie(new SetCookie($cookie));

View file

@ -2,6 +2,8 @@
namespace GuzzleHttp\Cookie; namespace GuzzleHttp\Cookie;
use GuzzleHttp\Psr7;
/** /**
* Set-Cookie object * Set-Cookie object
*/ */
@ -37,7 +39,9 @@ class SetCookie
// Create the default return array // Create the default return array
$data = self::$defaults; $data = self::$defaults;
// Explode the cookie string using a series of semicolons // Explode the cookie string using a series of semicolons
$pieces = \array_filter(\array_map('trim', \explode(';', $cookie))); $pieces = \array_filter(\array_map(static function (string $piece): string {
return \trim($piece, " \n\r\t\0\x0B");
}, \explode(';', $cookie)));
// The name of the cookie (first kvp) must exist and include an equal sign. // The name of the cookie (first kvp) must exist and include an equal sign.
if (!isset($pieces[0]) || \strpos($pieces[0], '=') === false) { if (!isset($pieces[0]) || \strpos($pieces[0], '=') === false) {
return new self($data); return new self($data);
@ -46,7 +50,7 @@ class SetCookie
// Add the cookie pieces into the parsed data array // Add the cookie pieces into the parsed data array
foreach ($pieces as $part) { foreach ($pieces as $part) {
$cookieParts = \explode('=', $part, 2); $cookieParts = \explode('=', $part, 2);
$key = \trim($cookieParts[0]); $key = \trim($cookieParts[0], " \n\r\t\0\x0B");
$value = isset($cookieParts[1]) $value = isset($cookieParts[1])
? \trim($cookieParts[1], " \n\r\t\0\x0B") ? \trim($cookieParts[1], " \n\r\t\0\x0B")
: true; : true;
@ -57,7 +61,7 @@ class SetCookie
$data['Value'] = $value; $data['Value'] = $value;
} else { } else {
foreach (\array_keys(self::$defaults) as $search) { foreach (\array_keys(self::$defaults) as $search) {
if (!\strcasecmp($search, $key)) { if (Psr7\Utils::caselessEquals($search, $key)) {
if ($search === 'Max-Age') { if ($search === 'Max-Age') {
if (is_numeric($value)) { if (is_numeric($value)) {
$data[$search] = (int) $value; $data[$search] = (int) $value;
@ -128,14 +132,27 @@ class SetCookie
} }
// Extract the Expires value and turn it into a UNIX timestamp if needed // Extract the Expires value and turn it into a UNIX timestamp if needed
if (!$this->getExpires() && $this->getMaxAge()) { $maxAge = $this->getMaxAge();
if (!$this->getExpires() && $maxAge !== null) {
// Calculate the Expires date // Calculate the Expires date
$this->setExpires(\time() + $this->getMaxAge()); $this->setExpires(self::maxAgeToExpires($maxAge, \time()));
} elseif (null !== ($expires = $this->getExpires()) && !\is_numeric($expires)) { } elseif (null !== ($expires = $this->getExpires()) && !\is_numeric($expires)) {
$this->setExpires($expires); $this->setExpires($expires);
} }
} }
private static function maxAgeToExpires(int $maxAge, int $now): int
{
if ($maxAge <= 0) {
return $now - 1;
}
if ($maxAge > \PHP_INT_MAX - $now) {
return \PHP_INT_MAX;
}
return $now + $maxAge;
}
public function __toString() public function __toString()
{ {
$str = $this->data['Name'].'='.($this->data['Value'] ?? '').'; '; $str = $this->data['Name'].'='.($this->data['Value'] ?? '').'; ';
@ -401,7 +418,7 @@ class SetCookie
$cookiePath = $this->getPath(); $cookiePath = $this->getPath();
// Match on exact matches or when path is the default empty "/" // Match on exact matches or when path is the default empty "/"
if ($cookiePath === '/' || $cookiePath == $requestPath) { if ($cookiePath === '/' || $cookiePath === $requestPath) {
return true; return true;
} }
@ -433,22 +450,57 @@ class SetCookie
// Remove the leading '.' as per spec in RFC 6265. // Remove the leading '.' as per spec in RFC 6265.
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3
$cookieDomain = \ltrim(\strtolower($cookieDomain), '.'); $cookieDomain = Psr7\Utils::asciiToLower($cookieDomain);
if ($cookieDomain !== '' && $cookieDomain[0] === '.') {
/** @var string */
$cookieDomain = \substr($cookieDomain, 1);
}
if ('' === $cookieDomain) {
return false;
}
$domain = \strtolower($domain); $domain = Psr7\Utils::asciiToLower($domain);
if ($domain === $cookieDomain) {
// Domain not set or exact match.
if ('' === $cookieDomain || $domain === $cookieDomain) {
return true; return true;
} }
// IP literals and numeric hosts are exact-match-only per RFC 6265.
// Only the exact match above may succeed for those cookie domains.
if (self::isIpAddressOrNumericHost($cookieDomain)) {
return false;
}
// Matching the subdomain according to RFC 6265. // Matching the subdomain according to RFC 6265.
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
if (\filter_var($domain, \FILTER_VALIDATE_IP)) { if (\filter_var($domain, \FILTER_VALIDATE_IP)) {
return false; return false;
} }
return (bool) \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/', $domain); return (bool) \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/D', $domain);
}
private static function isIpAddressOrNumericHost(string $host): bool
{
// Strip one root dot before detection so trailing-dot numeric hosts
// still cannot be matched by subdomains.
if ($host !== '' && \str_ends_with($host, '.')) {
$host = \substr($host, 0, -1);
}
if (\str_starts_with($host, '[') && \str_ends_with($host, ']')) {
$host = \substr($host, 1, -1);
}
if (\filter_var($host, \FILTER_VALIDATE_IP) !== false) {
return true;
}
// Public DNS names do not have an all-numeric rightmost label; treat
// those private/internal hosts as exact-match-only too.
$labels = \explode('.', $host);
$last = (string) \end($labels);
return $last !== '' && \ctype_digit($last);
} }
/** /**
@ -472,10 +524,7 @@ class SetCookie
} }
// Check if any of the invalid characters are present in the cookie name // Check if any of the invalid characters are present in the cookie name
if (\preg_match( if (\preg_match('/[\x00-\x20\x22\x28-\x29\x2c\x2f\x3a-\x40\x5c\x7b\x7d\x7f]/', $name) !== 0) {
'/[\x00-\x20\x22\x28-\x29\x2c\x2f\x3a-\x40\x5c\x7b\x7d\x7f]/',
$name
)) {
return 'Cookie name must not contain invalid characters: ASCII ' return 'Cookie name must not contain invalid characters: ASCII '
.'Control characters (0-31;127), space, tab and the ' .'Control characters (0-31;127), space, tab and the '
.'following characters: ()<>@,;:\"/?={}'; .'following characters: ()<>@,;:\"/?={}';
@ -491,7 +540,7 @@ class SetCookie
// Domains must not be empty, but may be omitted. "0" is not a valid // Domains must not be empty, but may be omitted. "0" is not a valid
// internet domain, but may be used as server name in a private network. // internet domain, but may be used as server name in a private network.
$domain = $this->getDomain(); $domain = $this->getDomain();
if ($domain === '') { if ($domain === '' || (null !== $domain && '' === \ltrim(\trim($domain, " \n\r\t\0\x0B"), '.'))) {
return 'The cookie domain must not be empty'; return 'The cookie domain must not be empty';
} }

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,11 @@ use Psr\Http\Message\RequestInterface;
*/ */
class CurlHandler class CurlHandler
{ {
private const KNOWN_CONSTRUCTOR_OPTIONS = [
'handle_factory' => true,
'transport_sharing' => true,
];
/** /**
* @var CurlFactoryInterface * @var CurlFactoryInterface
*/ */
@ -37,6 +42,12 @@ class CurlHandler
*/ */
public function __construct(array $options = []) public function __construct(array $options = [])
{ {
foreach ($options as $name => $_) {
if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) {
\trigger_deprecation('guzzlehttp/guzzle', '7.14', \sprintf('The "%s" CurlHandler constructor option is unknown; guzzlehttp/guzzle 8.0 will reject unknown constructor options.', (string) $name));
}
}
CurlShareHandleState::assertNoRequiredSharingCustomFactoryConflict($options, 'CurlHandler'); CurlShareHandleState::assertNoRequiredSharingCustomFactoryConflict($options, 'CurlHandler');
$transportSharing = $options['transport_sharing'] ?? null; $transportSharing = $options['transport_sharing'] ?? null;
$sharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing'); $sharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing');
@ -53,7 +64,7 @@ class CurlHandler
: null; : null;
$this->factory = $this->shareHandleState !== null $this->factory = $this->shareHandleState !== null
? new CurlFactory(3, $this->shareHandleState->mode, $this->shareHandleState->handle) ? new CurlFactory(3, $this->shareHandleState->mode, $this->shareHandleState)
: new CurlFactory(3); : new CurlFactory(3);
} }
@ -63,6 +74,11 @@ class CurlHandler
\usleep($options['delay'] * 1000); \usleep($options['delay'] * 1000);
} }
// A Multiplexing::NONE request option holds unconditionally here:
// transport sharing never shares the connection cache on this
// branch, and nothing else executes during the blocking curl_exec(),
// so the transfer cannot share its connection with a concurrent
// transfer.
$easy = $this->factory->create($request, $options); $easy = $this->factory->create($request, $options);
\curl_exec($easy->handle); \curl_exec($easy->handle);
$easy->errno = \curl_errno($easy->handle); $easy->errno = \curl_errno($easy->handle);

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,6 @@
namespace GuzzleHttp\Handler; namespace GuzzleHttp\Handler;
use GuzzleHttp\TransportSharing; use GuzzleHttp\TransportSharing;
use GuzzleHttp\Utils;
/** /**
* @internal * @internal
@ -70,7 +69,7 @@ final class CurlShareHandleState
throw new \InvalidArgumentException(\sprintf( throw new \InvalidArgumentException(\sprintf(
'The "%s" option must be null or a GuzzleHttp\\TransportSharing::* constant; received %s.', 'The "%s" option must be null or a GuzzleHttp\\TransportSharing::* constant; received %s.',
$option, $option,
Utils::describeType($sharing) \get_debug_type($sharing)
)); ));
} }
@ -108,7 +107,7 @@ final class CurlShareHandleState
self::requireCurlConstant('CURLOPT_SHARE'); self::requireCurlConstant('CURLOPT_SHARE');
$shareOption = self::requireCurlConstant('CURLSHOPT_SHARE'); $shareOption = self::requireCurlConstant('CURLSHOPT_SHARE');
$locks = self::handlerLocks(); $locks = self::handlerLocks($mode);
$handle = curl_share_init(); $handle = curl_share_init();
try { try {
@ -135,12 +134,23 @@ final class CurlShareHandleState
/** /**
* @return int[] * @return int[]
*/ */
private static function handlerLocks(): array private static function handlerLocks(string $mode): array
{ {
return [ CurlVersion::ensureHandlerSharingSupported();
if ($mode === TransportSharing::HANDLER_REQUIRE) {
CurlVersion::ensureSslSessionSharingSupported();
}
$locks = [
self::requireCurlConstant('CURL_LOCK_DATA_DNS'), self::requireCurlConstant('CURL_LOCK_DATA_DNS'),
self::requireCurlConstant('CURL_LOCK_DATA_SSL_SESSION'),
]; ];
if (CurlVersion::supportsSslSessionSharing()) {
$locks[] = self::requireCurlConstant('CURL_LOCK_DATA_SSL_SESSION');
}
return $locks;
} }
private static function requireCurlConstant(string $constant): int private static function requireCurlConstant(string $constant): int

View file

@ -0,0 +1,322 @@
<?php
namespace GuzzleHttp\Handler;
/**
* @internal
*/
final class CurlVersion
{
private const MIN_VERSION = '7.21.2';
private const TLS_12_VERSION = '7.34.0';
private const TLS_13_VERSION = '7.52.0';
private const CONNECTION_CAP_VERSION = '7.30.0';
// CURLOPT_PIPEWAIT exists since libcurl 7.43.0, and multi handles have
// multiplexed by default since 7.62.0 - but a 7.65.0-7.65.1 regression
// dropped that default, which 7.65.2 restored, so 7.65.2 is the floor at
// which PIPEWAIT is reliably effective.
private const MULTIPLEX_VERSION = '7.65.2';
// libcurl's connection matcher refuses to hand a transfer wanting
// HTTP/1.x a pooled connection that already negotiated HTTP/2 or newer
// from 7.77.0: ConnectionExists() in lib/url.c gained the check between
// the 7.76.0 and 7.77.0 releases. The HTTP/2 branch of the check
// regressed to a debug log in 8.11.0 (curl commit 433d730) and was
// restored in 8.13.0 via the negotiation mask (curl commit db72b8d), so
// 8.11.0 through 8.12.1 are vulnerable again.
private const HTTP_VERSION_REUSE_MATCH_VERSION = '7.77.0';
private const HTTP_VERSION_REUSE_MATCH_REGRESSION = '8.11.0';
private const HTTP_VERSION_REUSE_MATCH_RESTORED = '8.13.0';
// CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE restricts the ALPN offer to h2 only
// since libcurl 8.10.0, and connection reuse matching stopped handing
// lower-version connections to prior-knowledge transfers in 8.14.0; below
// that, a required request could silently be sent over a reused HTTP/1.1
// connection.
private const REQUIRED_MULTIPLEX_VERSION = '8.14.0';
// curl 7.52.0 introduced HTTPS proxy support, advertised by a feature bit
// (a build can meet the version yet lack the feature). Earlier libcurl
// mishandles an https:// proxy: before 7.50.2 it silently downgrades to a
// plaintext HTTP proxy, and 7.50.2 through 7.51 reject it at connect time.
private const HTTPS_PROXY_VERSION = '7.52.0';
private const HANDLER_SHARING_VERSION = '7.35.0';
private const SSL_SESSION_SHARING_VERSION = '8.6.0';
// curl 7.57.0 added share-handle connection caches through
// CURL_LOCK_DATA_CONNECT; older share objects can only hold DNS, TLS
// session, and cookie data, never connections.
private const SHARE_CONNECTION_CACHE_VERSION = '7.57.0';
// curl 7.83.1 added proxy TLS-SRP to the connection-reuse match
// (CVE-2022-27782); the proxy client certificate was matched from 7.52.0,
// so proxy TLS credentials are trusted from 7.83.1 onwards.
private const PROXY_TLS_CREDENTIAL_REUSE_VERSION = '7.83.1';
// curl 8.19.0 fixed proxy tunnel reuse after credential changes
// (CVE-2026-3784), but related proxy credential leak flaws were only
// fixed in 8.20.0, so connection reuse is trusted from 8.20.0 onwards.
private const PROXY_CREDENTIAL_REUSE_VERSION = '8.20.0';
// curl 7.69.0 started comparing SOCKS proxy credentials when matching
// connections for reuse (curl #4835); older libcurl matches a SOCKS proxy
// by type, host, and port only.
private const SOCKS_PROXY_CREDENTIAL_REUSE_VERSION = '7.69.0';
private const PROXY_HEADER_SEPARATION_VERSION = '7.37.0';
/**
* @var array{version: string, features: int}|false|null
*/
private static $versionInfo;
private function __construct()
{
}
public static function supportsCurlHandler(): bool
{
$version = self::getVersion();
return $version !== null && \version_compare($version, self::MIN_VERSION, '>=');
}
public static function supportsTls12(): bool
{
$version = self::getVersion();
return self::supportsSsl()
&& \defined('CURL_SSLVERSION_TLSv1_2')
&& $version !== null
&& \version_compare($version, self::TLS_12_VERSION, '>=');
}
public static function supportsTls13(): bool
{
$version = self::getVersion();
return self::supportsSsl()
&& \defined('CURL_SSLVERSION_TLSv1_3')
&& $version !== null
&& \version_compare($version, self::TLS_13_VERSION, '>=');
}
public static function supportsHttp2(): bool
{
$versionInfo = self::getVersionInfo();
return self::supportsTls12()
&& \defined('CURL_VERSION_HTTP2')
&& $versionInfo !== null
&& 0 !== (\CURL_VERSION_HTTP2 & $versionInfo['features']);
}
public static function supportsMultiplex(): bool
{
$version = self::getVersion();
return \defined('CURLOPT_PIPEWAIT')
&& $version !== null
&& \version_compare($version, self::MULTIPLEX_VERSION, '>=');
}
public static function supportsHttpVersionReuseMatching(): bool
{
$version = self::getVersion();
if ($version === null || \version_compare($version, self::HTTP_VERSION_REUSE_MATCH_VERSION, '<')) {
return false;
}
return \version_compare($version, self::HTTP_VERSION_REUSE_MATCH_REGRESSION, '<')
|| \version_compare($version, self::HTTP_VERSION_REUSE_MATCH_RESTORED, '>=');
}
public static function supportsConnectionCaps(): bool
{
$version = self::getVersion();
return \defined('CURLMOPT_MAX_HOST_CONNECTIONS')
&& \defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')
&& $version !== null
&& \version_compare($version, self::CONNECTION_CAP_VERSION, '>=');
}
public static function ensureConnectionCapsSupported(string $option): void
{
if (self::supportsConnectionCaps()) {
return;
}
throw new \InvalidArgumentException(\sprintf(
'The "%s" option requires PHP cURL support for CURLMOPT_MAX_HOST_CONNECTIONS and CURLMOPT_MAX_TOTAL_CONNECTIONS with libcurl %s or newer.',
$option,
self::CONNECTION_CAP_VERSION
));
}
public static function supportsRequiredMultiplex(): bool
{
$version = self::getVersion();
return \defined('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE')
&& $version !== null
&& self::supportsHttp2()
&& \version_compare($version, self::REQUIRED_MULTIPLEX_VERSION, '>=');
}
public static function supportsHttpsProxy(): bool
{
$versionInfo = self::getVersionInfo();
// CURL_VERSION_HTTPS_PROXY is not defined on every supported PHP
// version; fall back to the curl.h bit value.
$httpsProxyFeature = \defined('CURL_VERSION_HTTPS_PROXY') ? \CURL_VERSION_HTTPS_PROXY : (1 << 21);
return $versionInfo !== null
&& \version_compare($versionInfo['version'], self::HTTPS_PROXY_VERSION, '>=')
&& 0 !== ($httpsProxyFeature & $versionInfo['features']);
}
public static function supportsNtlm(): bool
{
$versionInfo = self::getVersionInfo();
// CURL_VERSION_NTLM is not defined on every supported PHP version; fall
// back to the curl.h bit value.
$ntlmFeature = \defined('CURL_VERSION_NTLM') ? \CURL_VERSION_NTLM : (1 << 4);
return \defined('CURLAUTH_NTLM')
&& $versionInfo !== null
&& 0 !== ($ntlmFeature & $versionInfo['features']);
}
public static function supportsHandlerSharing(): bool
{
$version = self::getVersion();
return $version !== null && \version_compare($version, self::HANDLER_SHARING_VERSION, '>=');
}
public static function ensureHandlerSharingSupported(): void
{
if (!self::supportsHandlerSharing()) {
throw new \InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires libcurl %s or higher for cURL share handles.',
self::HANDLER_SHARING_VERSION
));
}
}
public static function supportsSslSessionSharing(): bool
{
$version = self::getVersion();
return self::supportsSsl()
&& $version !== null
&& \version_compare($version, self::SSL_SESSION_SHARING_VERSION, '>=');
}
public static function ensureSslSessionSharingSupported(): void
{
if (!self::supportsSslSessionSharing()) {
throw new \InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires libcurl %s or higher with SSL support for SSL session sharing.',
self::SSL_SESSION_SHARING_VERSION
));
}
}
public static function supportsShareConnectionCaches(): bool
{
$version = self::getVersion();
// An undetectable libcurl version is treated as capable so the
// opaque share safeguards fail closed.
return $version === null || \version_compare($version, self::SHARE_CONNECTION_CACHE_VERSION, '>=');
}
public static function supportsProxyTlsCredentialAwareConnectionReuse(): bool
{
$version = self::getVersion();
return $version !== null
&& \version_compare($version, self::PROXY_TLS_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsProxyCredentialAwareConnectionReuse(): bool
{
$version = self::getVersion();
return $version !== null
&& \version_compare($version, self::PROXY_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsSocksProxyCredentialAwareConnectionReuse(): bool
{
$version = self::getVersion();
return $version !== null
&& \version_compare($version, self::SOCKS_PROXY_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsProxyHeaderSeparation(): bool
{
$version = self::getVersion();
return $version !== null
&& \version_compare($version, self::PROXY_HEADER_SEPARATION_VERSION, '>=')
&& \defined('CURLOPT_PROXYHEADER')
&& \defined('CURLOPT_HEADEROPT')
&& \defined('CURLHEADER_SEPARATE');
}
private static function supportsSsl(): bool
{
$versionInfo = self::getVersionInfo();
return \defined('CURL_VERSION_SSL')
&& $versionInfo !== null
&& 0 !== (\CURL_VERSION_SSL & $versionInfo['features']);
}
public static function getVersion(): ?string
{
$versionInfo = self::getVersionInfo();
return $versionInfo === null ? null : $versionInfo['version'];
}
/**
* @return array{version: string, features: int}|null
*/
private static function getVersionInfo(): ?array
{
if (self::$versionInfo === null) {
if (!\function_exists('curl_version')) {
self::$versionInfo = false;
} else {
$versionInfo = \curl_version();
self::$versionInfo = \is_array($versionInfo)
&& isset($versionInfo['version'], $versionInfo['features'])
&& \is_string($versionInfo['version'])
&& \is_int($versionInfo['features'])
? [
'version' => $versionInfo['version'],
'features' => $versionInfo['features'],
]
: false;
}
}
return self::$versionInfo === false ? null : self::$versionInfo;
}
}

View file

@ -30,6 +30,17 @@ final class EasyHandle
*/ */
public $headers = []; public $headers = [];
/**
* @var array Valid trailer lines, retained only when an on_trailers
* callback is configured
*/
public $trailers = [];
/**
* @var bool Whether this handle was configured with CURLOPT_PIPEWAIT
*/
public $usesPipewait = false;
/** /**
* @var ResponseInterface|null Received response (if any) * @var ResponseInterface|null Received response (if any)
*/ */
@ -50,6 +61,19 @@ final class EasyHandle
*/ */
public $errno = 0; public $errno = 0;
/**
* @var string|null Effective CURLOPT_PROXY value the handle was created with (if any)
*/
public $effectiveProxy;
/**
* Proxy tunnel or SOCKS proxy section signature for connection-reuse
* isolation, or null when the request does not require sectioning.
*
* @var string|null
*/
public $proxyTunnelSignature;
/** /**
* @var \Throwable|null Exception during on_headers (if any) * @var \Throwable|null Exception during on_headers (if any)
*/ */
@ -74,7 +98,7 @@ final class EasyHandle
$normalizedKeys = Utils::normalizeHeaderKeys($headers); $normalizedKeys = Utils::normalizeHeaderKeys($headers);
if (!empty($this->options['decode_content']) && isset($normalizedKeys['content-encoding'])) { if (isset($this->options['decode_content']) && $this->options['decode_content'] !== false && isset($normalizedKeys['content-encoding'])) {
$headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']]; $headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']];
unset($headers[$normalizedKeys['content-encoding']]); unset($headers[$normalizedKeys['content-encoding']]);
if (isset($normalizedKeys['content-length'])) { if (isset($normalizedKeys['content-length'])) {

View file

@ -44,7 +44,7 @@ final class HeaderProcessor
throw new \RuntimeException('HTTP status code missing from header data'); throw new \RuntimeException('HTTP status code missing from header data');
} }
if (!\preg_match('/^\d{3}$/', $status)) { if (!\preg_match('/^\d{3}$/D', $status)) {
throw new \RuntimeException('HTTP status code is invalid'); throw new \RuntimeException('HTTP status code is invalid');
} }
@ -57,6 +57,26 @@ final class HeaderProcessor
return [$version, (int) $status, $parts[2] ?? null, Utils::headersFromLines($headers)]; return [$version, (int) $status, $parts[2] ?? null, Utils::headersFromLines($headers)];
} }
public static function isStatusLineCandidate(string $line): bool
{
return \preg_match('/^HTTP\/[0-9]+(?:\.[0-9]+)? [0-9]{3}(?: [^\r\n]*)?(?:\r\n|\r|\n)?$/iD', $line) === 1;
}
public static function isValidHeaderFieldLine(string $line): bool
{
$parts = \explode(':', $line, 2);
if (!isset($parts[1])) {
return false;
}
if (!\preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $parts[0])) {
return false;
}
return \preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*(?:\r\n|\r|\n)?$/D', \trim($parts[1], " \t")) === 1;
}
/** /**
* @param non-empty-list<string> $headers * @param non-empty-list<string> $headers
* *
@ -67,7 +87,7 @@ final class HeaderProcessor
$lastStatusLine = 0; $lastStatusLine = 0;
foreach ($headers as $index => $line) { foreach ($headers as $index => $line) {
if (\preg_match('/^HTTP\/\S+\s+/i', $line)) { if (self::isStatusLineCandidate($line)) {
$lastStatusLine = $index; $lastStatusLine = $index;
} }
} }

View file

@ -7,7 +7,6 @@ use GuzzleHttp\HandlerStack;
use GuzzleHttp\Promise as P; use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\TransferStats; use GuzzleHttp\TransferStats;
use GuzzleHttp\Utils;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface; use Psr\Http\Message\StreamInterface;
@ -160,7 +159,7 @@ class MockHandler implements \Countable
) { ) {
$this->queue[] = $value; $this->queue[] = $value;
} else { } else {
throw new \TypeError('Expected a Response, Promise, Throwable or callable. Found '.Utils::describeType($value)); throw new \TypeError('Expected a Response, Promise, Throwable or callable. Found '.\get_debug_type($value));
} }
} }
} }

View file

@ -48,4 +48,34 @@ class Proxy
return empty($options['stream']) ? $default($request, $options) : $streaming($request, $options); return empty($options['stream']) ? $default($request, $options) : $streaming($request, $options);
}; };
} }
/**
* Sends requests to a fallback handler when the default cURL handler cannot
* honor TLS 1.2 selection.
*
* @param callable(RequestInterface, array): PromiseInterface $default
* @param callable(RequestInterface, array): PromiseInterface $fallback
*
* @return callable(RequestInterface, array): PromiseInterface Returns the composed handler.
*/
public static function wrapTlsFallback(callable $default, callable $fallback): callable
{
return static function (RequestInterface $request, array $options) use ($default, $fallback): PromiseInterface {
if (self::requiresTls12Fallback($options)) {
return $fallback($request, $options);
}
return $default($request, $options);
};
}
/**
* @param array<string, mixed> $options
*/
private static function requiresTls12Fallback(array $options): bool
{
return isset($options[RequestOptions::CRYPTO_METHOD])
&& $options[RequestOptions::CRYPTO_METHOD] === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
&& !CurlVersion::supportsTls12();
}
} }

View file

@ -0,0 +1,121 @@
<?php
namespace GuzzleHttp\Handler;
use GuzzleHttp\Psr7;
/**
* Resolves proxy configuration from the process environment with the same
* semantics libcurl applies, so the cURL handlers can pin CURLOPT_PROXY and
* CURLOPT_NOPROXY explicitly and libcurl never reads the environment itself.
*
* @internal
*/
final class ProxyEnvironment
{
private function __construct()
{
}
/**
* Resolves the proxy to use for the given request scheme.
*
* The lookup mirrors libcurl for the http and https schemes the handlers
* accept: the lowercase scheme-specific variable first, its uppercase
* variant next (except for "http", where uppercase HTTP_PROXY is never
* read), then all_proxy/ALL_PROXY.
*
* @return string|null The proxy to use; null when the environment
* configures none.
*/
public static function getProxyForScheme(string $scheme): ?string
{
$scheme = Psr7\Utils::asciiToLower($scheme);
$candidates = [$scheme.'_proxy'];
if ($scheme !== 'http') {
// Uppercase HTTP_PROXY is deliberately never consulted: a CGI
// request header "Proxy:" becomes HTTP_PROXY in the environment.
// See https://httpoxy.org for more information.
$candidates[] = Psr7\Utils::asciiToUpper($scheme).'_PROXY';
}
$candidates[] = 'all_proxy';
$candidates[] = 'ALL_PROXY';
foreach ($candidates as $name) {
$value = self::getenv($name);
if ($value !== null) {
return $value;
}
}
return null;
}
/**
* @return string|null The no-proxy list; null when nothing is set.
*/
public static function getNoProxy(): ?string
{
foreach (['no_proxy', 'NO_PROXY'] as $name) {
$value = self::getenv($name);
if ($value !== null) {
return $value;
}
}
return null;
}
/**
* Splits a no_proxy environment value into matchable entries.
*
* Mirrors libcurl's tokenization: entries may be separated by commas or
* blanks, and a single leading dot is ignored, so ".example.com" bypasses
* example.com and its subdomains exactly as a bare domain entry does.
*
* @return string[]
*/
public static function splitNoProxy(string $noProxy): array
{
$entries = [];
$split = \preg_split('/[\s,]+/', $noProxy);
if ($split === false) {
throw new \RuntimeException('Unable to split the no_proxy value: '.\preg_last_error_msg());
}
foreach ($split as $entry) {
if ($entry !== '' && $entry[0] === '.') {
$entry = \substr($entry, 1);
}
if ($entry !== '') {
$entries[] = $entry;
}
}
return $entries;
}
private static function getenv(string $name): ?string
{
// Windows environment variables are case-insensitive, so the
// lowercase-only httpoxy defence does not hold there. Outside the
// CLI SAPI on Windows, environment proxies are not resolved at all
// (a safe-side divergence from libcurl).
if (\PHP_OS_FAMILY === 'Windows' && \PHP_SAPI !== 'cli') {
return null;
}
// local_only: the OS environment and putenv() only - the same
// environ(7) libcurl reads. SAPI request environments such as
// fastcgi_param or SetEnv are deliberately excluded.
$value = \getenv($name, true);
// libcurl's GetEnv (lib/getenv.c) treats variables set to an empty
// string as unset on every version, so the lookup falls through to
// the next candidate.
return $value === false || $value === '' ? null : $value;
}
}

View file

@ -4,6 +4,7 @@ namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Multiplexing;
use GuzzleHttp\Promise as P; use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise\PromiseInterface;
@ -23,6 +24,12 @@ use Psr\Http\Message\UriInterface;
*/ */
class StreamHandler class StreamHandler
{ {
private const KNOWN_CONSTRUCTOR_OPTIONS = [
'max_host_connections' => true,
'max_total_connections' => true,
'transport_sharing' => true,
];
private const CONNECTION_ERRORS = [ private const CONNECTION_ERRORS = [
'php_network_getaddresses:', 'php_network_getaddresses:',
'getaddrinfo', 'getaddrinfo',
@ -46,6 +53,61 @@ class StreamHandler
*/ */
private $lastHeaders = []; private $lastHeaders = [];
/**
* @var string
*/
private $transportSharingMode;
/**
* @var bool
*/
private $connectionCapsConfigured = false;
/**
* Accepts an associative array of options:
*
* - max_host_connections: Optional positive integer or null. A non-null
* value marks the handler as incompatible with enabled response
* streaming; the number is not used for stream-handler admission.
* - max_total_connections: Optional positive integer or null. A non-null
* value marks the handler as incompatible with enabled response
* streaming; the number is not used for stream-handler admission.
* - transport_sharing: Optional transport sharing mode.
*
* The stream handler cannot cap streamed connections, so a configured cap
* marker rejects enabled response streaming ("stream" => true). Accepted
* transfers are buffered and hold at most one connection per in-flight
* call, but overlapping buffered calls are not collectively limited.
*
* @param array{max_host_connections?: mixed, max_total_connections?: mixed, transport_sharing?: mixed} $options Array of options to use with the handler
*/
public function __construct(array $options = [])
{
foreach ($options as $name => $_) {
if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) {
\trigger_deprecation('guzzlehttp/guzzle', '7.14', \sprintf('The "%s" StreamHandler constructor option is unknown; guzzlehttp/guzzle 8.0 will reject unknown constructor options.', (string) $name));
}
}
$this->transportSharingMode = CurlShareHandleState::normalizeMode(
$options['transport_sharing'] ?? null,
'transport_sharing'
);
foreach (['max_host_connections', 'max_total_connections'] as $capOption) {
$value = $options[$capOption] ?? null;
if ($value === null) {
continue;
}
if (!\is_int($value) || $value < 1) {
throw new \InvalidArgumentException(\sprintf('%s must be a positive integer.', $capOption));
}
$this->connectionCapsConfigured = true;
}
}
/** /**
* Sends an HTTP request. * Sends an HTTP request.
* *
@ -59,6 +121,29 @@ class StreamHandler
\usleep($options['delay'] * 1000); \usleep($options['delay'] * 1000);
} }
$multiplex = $options['multiplex'] ?? null;
// Multiplexing::NONE is trivially satisfied: the stream handler sends
// one HTTP/1.x request per connection and never multiplexes.
if (null !== $multiplex && !\in_array($multiplex, [Multiplexing::NONE, Multiplexing::EAGER, Multiplexing::WAIT, Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) {
throw new \InvalidArgumentException(\sprintf(
'The "multiplex" option must be null or a GuzzleHttp\\Multiplexing::* constant; received %s.',
\get_debug_type($multiplex)
));
}
if (\in_array($multiplex, [Multiplexing::REQUIRE_EAGER, Multiplexing::REQUIRE_WAIT], true)) {
throw new ConnectException('The stream handler cannot guarantee a multiplexed protocol; required multiplexing needs a cURL handler.', $request);
}
if ($this->connectionCapsConfigured && !empty($options['stream'])) {
throw new \InvalidArgumentException('Enabling the "stream" request option on a stream handler configured with the "max_host_connections" or "max_total_connections" option is not supported because streamed connections cannot be capped.');
}
if (isset($options['on_trailers'])) {
throw new \InvalidArgumentException('Passing the "on_trailers" request option to the stream handler is not supported because the stream handler cannot observe trailers.');
}
$protocolVersion = $request->getProtocolVersion(); $protocolVersion = $request->getProtocolVersion();
if ('' === $protocolVersion) { if ('' === $protocolVersion) {
@ -75,6 +160,7 @@ class StreamHandler
$startTime = isset($options['on_stats']) ? Utils::currentTime() : null; $startTime = isset($options['on_stats']) ? Utils::currentTime() : null;
self::triggerUnsupportedRequestOptionDeprecations($request, $options); self::triggerUnsupportedRequestOptionDeprecations($request, $options);
$this->assertTransportSharingSupported();
try { try {
// Does not support the expect header. // Does not support the expect header.
@ -84,8 +170,8 @@ class StreamHandler
// the behavior of `CurlHandler` // the behavior of `CurlHandler`
if ( if (
( (
0 === \strcasecmp('PUT', $request->getMethod()) Psr7\Utils::caselessEquals('PUT', $request->getMethod())
|| 0 === \strcasecmp('POST', $request->getMethod()) || Psr7\Utils::caselessEquals('POST', $request->getMethod())
) )
&& 0 === $request->getBody()->getSize() && 0 === $request->getBody()->getSize()
) { ) {
@ -155,7 +241,7 @@ class StreamHandler
$stream = Psr7\Utils::streamFor($stream); $stream = Psr7\Utils::streamFor($stream);
$sink = $stream; $sink = $stream;
if (\strcasecmp('HEAD', $request->getMethod())) { if (!Psr7\Utils::caselessEquals('HEAD', $request->getMethod())) {
$sink = $this->createSink($stream, $options); $sink = $this->createSink($stream, $options);
} }
@ -221,7 +307,7 @@ class StreamHandler
private function checkDecode(array $options, array $headers, $stream): array private function checkDecode(array $options, array $headers, $stream): array
{ {
// Automatically decode responses when instructed. // Automatically decode responses when instructed.
if (!empty($options['decode_content'])) { if (isset($options['decode_content']) && $options['decode_content'] !== false) {
$normalizedKeys = Utils::normalizeHeaderKeys($headers); $normalizedKeys = Utils::normalizeHeaderKeys($headers);
if (isset($normalizedKeys['content-encoding'])) { if (isset($normalizedKeys['content-encoding'])) {
$encoding = $headers[$normalizedKeys['content-encoding']]; $encoding = $headers[$normalizedKeys['content-encoding']];
@ -307,7 +393,7 @@ class StreamHandler
$message .= "[$key] $value".\PHP_EOL; $message .= "[$key] $value".\PHP_EOL;
} }
} }
throw new \RuntimeException(\trim($message)); throw new \RuntimeException(\trim($message, " \n\r\t\0\x0B"));
} }
return $resource; return $resource;
@ -323,7 +409,12 @@ class StreamHandler
$methods = \array_flip(\get_class_methods(__CLASS__)); $methods = \array_flip(\get_class_methods(__CLASS__));
} }
$scheme = $request->getUri()->getScheme(); $uri = $request->getUri();
$scheme = $uri->getScheme();
if ($scheme === '') {
throw new RequestException('URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $request);
}
if (!\in_array($scheme, ['http', 'https'], true)) { if (!\in_array($scheme, ['http', 'https'], true)) {
throw new RequestException(\sprintf("The scheme '%s' is not supported.", $scheme), $request); throw new RequestException(\sprintf("The scheme '%s' is not supported.", $scheme), $request);
} }
@ -333,6 +424,10 @@ class StreamHandler
throw new RequestException(\sprintf('The scheme "%s" is not allowed by the protocols request option.', $scheme), $request); throw new RequestException(\sprintf('The scheme "%s" is not allowed by the protocols request option.', $scheme), $request);
} }
if ($uri->getHost() === '') {
throw new RequestException('URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $request);
}
// HTTP/1.1 streams using the PHP stream wrapper require a // HTTP/1.1 streams using the PHP stream wrapper require a
// Connection: close header // Connection: close header
if ($request->getProtocolVersion() === '1.1' if ($request->getProtocolVersion() === '1.1'
@ -353,10 +448,19 @@ class StreamHandler
throw new \InvalidArgumentException('on_headers must be callable'); throw new \InvalidArgumentException('on_headers must be callable');
} }
self::assertTlsVersionRangeForOptions($options);
$proxyAuthorizationAdded = false;
if (!empty($options)) { if (!empty($options)) {
foreach ($options as $key => $value) { foreach ($options as $key => $value) {
$method = "add_{$key}"; $method = "add_{$key}";
if (isset($methods[$method])) { if (isset($methods[$method])) {
if ($method === 'add_proxy') {
$proxyAuthorizationAdded = $this->add_proxy($request, $context, $value, $params);
continue;
}
$this->{$method}($request, $context, $value, $params); $this->{$method}($request, $context, $value, $params);
} }
} }
@ -366,6 +470,16 @@ class StreamHandler
if (!\is_array($options['stream_context'])) { if (!\is_array($options['stream_context'])) {
throw new \InvalidArgumentException('stream_context must be an array'); throw new \InvalidArgumentException('stream_context must be an array');
} }
if (
$proxyAuthorizationAdded
&& isset($options['stream_context']['http'])
&& \is_array($options['stream_context']['http'])
&& \array_key_exists('proxy', $options['stream_context']['http'])
) {
throw new \InvalidArgumentException('stream_context.http.proxy cannot override a proxy after the stream handler has generated a Proxy-Authorization header; configure the final proxy with the "proxy" request option.');
}
self::triggerConflictingStreamContextOptionDeprecations($options['stream_context']);
self::triggerUnsupportedStreamContextOptionDeprecations($options['stream_context']);
$context = \array_replace_recursive($context, $options['stream_context']); $context = \array_replace_recursive($context, $options['stream_context']);
} }
@ -394,7 +508,7 @@ class StreamHandler
$this->lastHeaders = $http_response_header ?? []; $this->lastHeaders = $http_response_header ?? [];
if (false === $resource) { if (false === $resource) {
throw new ConnectException(sprintf('Connection refused for URI %s', $uri), $request, null, $context); throw new ConnectException(sprintf('Connection refused for URI %s', Psr7\Utils::redactUserInfo($uri)), $request, null, $context);
} }
if (isset($options['read_timeout'])) { if (isset($options['read_timeout'])) {
@ -413,7 +527,11 @@ class StreamHandler
{ {
$uri = $request->getUri(); $uri = $request->getUri();
if (isset($options['force_ip_resolve']) && !\filter_var($uri->getHost(), \FILTER_VALIDATE_IP)) { $host = $uri->getHost();
$hostForIpCheck = $host !== '' && $host[0] === '[' && \substr($host, -1) === ']'
? \substr($host, 1, -1)
: $host;
if (isset($options['force_ip_resolve']) && !\filter_var($hostForIpCheck, \FILTER_VALIDATE_IP)) {
if ('v4' === $options['force_ip_resolve']) { if ('v4' === $options['force_ip_resolve']) {
$records = \dns_get_record($uri->getHost(), \DNS_A); $records = \dns_get_record($uri->getHost(), \DNS_A);
if (false === $records || !isset($records[0]['ip'])) { if (false === $records || !isset($records[0]['ip'])) {
@ -439,6 +557,17 @@ class StreamHandler
{ {
$headers = ''; $headers = '';
foreach ($request->getHeaders() as $name => $value) { foreach ($request->getHeaders() as $name => $value) {
// A first-class Proxy-Authorization header is proxy-scoped. Keep
// it out of the origin context; add_proxy() adds one
// validated canonical line only when Guzzle selects a proxy; PHP
// extracts that line for CONNECT and removes it before sending the
// tunneled origin request. The caselessEquals() helper is
// locale-independent, unlike strcasecmp(), so a locale cannot
// make this match miss and re-leak the credential.
if (Psr7\Utils::caselessEquals((string) $name, 'Proxy-Authorization')) {
continue;
}
foreach ($value as $val) { foreach ($value as $val) {
$headers .= "$name: $val\r\n"; $headers .= "$name: $val\r\n";
} }
@ -467,21 +596,13 @@ class StreamHandler
} }
} }
$context['http']['header'] = \rtrim($context['http']['header']); $context['http']['header'] = \rtrim($context['http']['header'], " \n\r\t\0\x0B");
return $context; return $context;
} }
private static function triggerUnsupportedRequestOptionDeprecations(RequestInterface $request, array $options): void private static function triggerUnsupportedRequestOptionDeprecations(RequestInterface $request, array $options): void
{ {
if (\array_key_exists('transport_sharing', $options)) {
$transportSharingMode = CurlShareHandleState::normalizeMode($options['transport_sharing'], 'transport_sharing');
if ($transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
throw new \InvalidArgumentException('The "transport_sharing" option requires transport sharing, but the stream handler does not support it.');
}
}
if ( if (
\array_key_exists('curl', $options) \array_key_exists('curl', $options)
&& $options['curl'] !== null && $options['curl'] !== null
@ -491,15 +612,170 @@ class StreamHandler
\trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "curl" request option to the stream handler is deprecated; guzzlehttp/guzzle 8.0 will reject this option because the stream handler ignores cURL options.'); \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "curl" request option to the stream handler is deprecated; guzzlehttp/guzzle 8.0 will reject this option because the stream handler ignores cURL options.');
} }
if (self::usesDigestAuth($options)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing digest authentication to the stream handler is deprecated; guzzlehttp/guzzle 8.0 will reject digest authentication with the stream handler because it is only supported by cURL handlers.');
}
if (\array_key_exists('expect', $options) && $options['expect'] !== false && $request->hasHeader('Expect')) { if (\array_key_exists('expect', $options) && $options['expect'] !== false && $request->hasHeader('Expect')) {
\trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "expect" request option to the stream handler is deprecated when it adds an Expect header; guzzlehttp/guzzle 8.0 will reject this option because the stream handler does not support Expect: 100-Continue.'); \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "expect" request option to the stream handler is deprecated when it adds an Expect header; guzzlehttp/guzzle 8.0 will reject this option because the stream handler does not support Expect: 100-Continue.');
} }
} }
private static function triggerConflictingStreamContextOptionDeprecations(array $streamContext): void
{
$conflictingOptions = self::conflictingStreamContextOptions();
foreach ($streamContext as $wrapper => $contextOptions) {
if (!\is_string($wrapper) || !isset($conflictingOptions[$wrapper]) || !\is_array($contextOptions)) {
continue;
}
foreach ($contextOptions as $option => $_) {
if (!\is_string($option) || !\array_key_exists($option, $conflictingOptions[$wrapper])) {
continue;
}
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.12',
\sprintf(
'Passing stream_context.%s.%s in the "stream_context" request option is deprecated; guzzlehttp/guzzle 8.0 will reject this option because it conflicts with Guzzle-managed request handling. Use %s instead.',
$wrapper,
$option,
$conflictingOptions[$wrapper][$option]
)
);
}
}
}
private static function triggerUnsupportedStreamContextOptionDeprecations(array $streamContext): void
{
$unsupportedOptions = self::unsupportedStreamContextOptions($streamContext);
if ($unsupportedOptions === []) {
return;
}
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.12',
\sprintf(
'Passing PHP stream context options outside the built-in stream handler allow-list to the "stream_context" request option is deprecated; guzzlehttp/guzzle 8.0 will reject stream context options outside the allow-list. Deprecated option%s: %s.',
\count($unsupportedOptions) === 1 ? '' : 's',
\implode(', ', $unsupportedOptions)
)
);
}
/**
* @return string[]
*/
private static function unsupportedStreamContextOptions(array $streamContext): array
{
$supportedOptions = self::supportedStreamContextOptions();
$conflictingOptions = self::conflictingStreamContextOptions();
$unsupportedOptions = [];
foreach ($streamContext as $wrapper => $contextOptions) {
if (!\is_string($wrapper) || !isset($supportedOptions[$wrapper])) {
if (\is_array($contextOptions)) {
foreach ($contextOptions as $option => $_) {
if (\is_string($wrapper) && \is_string($option) && isset($conflictingOptions[$wrapper]) && \array_key_exists($option, $conflictingOptions[$wrapper])) {
continue;
}
$unsupportedOptions[] = \sprintf('stream_context.%s.%s', (string) $wrapper, (string) $option);
}
} else {
$unsupportedOptions[] = \sprintf('stream_context.%s', (string) $wrapper);
}
continue;
}
if (!\is_array($contextOptions)) {
$unsupportedOptions[] = \sprintf('stream_context.%s', $wrapper);
continue;
}
foreach ($contextOptions as $option => $_) {
if (\is_string($option) && isset($conflictingOptions[$wrapper]) && \array_key_exists($option, $conflictingOptions[$wrapper])) {
continue;
}
if (!\is_string($option) || !\array_key_exists($option, $supportedOptions[$wrapper])) {
$unsupportedOptions[] = \sprintf('stream_context.%s.%s', $wrapper, (string) $option);
}
}
}
return $unsupportedOptions;
}
/**
* @return array<string, array<string, true>>
*/
private static function supportedStreamContextOptions(): array
{
return [
'http' => [
'request_fulluri' => true,
],
'socket' => [
'bindto' => true,
'tcp_nodelay' => true,
],
'ssl' => [
'SNI_enabled' => true,
'capture_peer_cert' => true,
'capture_peer_cert_chain' => true,
'ciphers' => true,
'disable_compression' => true,
'no_ticket' => true,
'peer_fingerprint' => true,
'security_level' => true,
'verify_depth' => true,
],
];
}
/**
* @return array<string, array<string, string>>
*/
private static function conflictingStreamContextOptions(): array
{
return [
'http' => [
'content' => 'the request body',
'follow_location' => 'the "allow_redirects" request option',
'header' => 'the request headers',
'max_redirects' => 'the "allow_redirects" request option',
'method' => 'the request method',
'protocol_version' => 'the request protocol version',
'proxy' => 'the "proxy" request option',
'timeout' => 'the "timeout" request option',
],
'ssl' => [
'allow_self_signed' => 'the "verify" request option',
'cafile' => 'the "verify" request option',
'capath' => 'the "verify" request option',
'crypto_method' => 'the "crypto_method" request option',
'local_cert' => 'the "cert" request option',
'local_pk' => 'the "ssl_key" request option',
'max_proto_version' => 'the "crypto_method_max" request option',
'min_proto_version' => 'the "crypto_method" request option',
'passphrase' => 'the "cert" or "ssl_key" request option',
'peer_name' => 'the request URI',
'verify_peer' => 'the "verify" request option',
'verify_peer_name' => 'the "verify" request option',
],
];
}
private function assertTransportSharingSupported(): void
{
if ($this->transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
throw new \InvalidArgumentException('The "transport_sharing" option requires transport sharing, but the stream handler does not support it.');
}
}
private static function isCurlOptionGeneratedByAuth(array $options): bool private static function isCurlOptionGeneratedByAuth(array $options): bool
{ {
if (!isset($options['curl']) || !\is_array($options['curl']) || !isset($options['auth'][2]) || !\is_string($options['auth'][2])) { if (!isset($options['curl']) || !\is_array($options['curl']) || !isset($options['auth'][2]) || !\is_string($options['auth'][2])) {
@ -510,7 +786,7 @@ class StreamHandler
return false; return false;
} }
$type = \strtolower($options['auth'][2]); $type = Psr7\Utils::asciiToLower($options['auth'][2]);
if ($type === 'digest') { if ($type === 'digest') {
$httpAuth = \defined('CURLAUTH_DIGEST') ? \constant('CURLAUTH_DIGEST') : null; $httpAuth = \defined('CURLAUTH_DIGEST') ? \constant('CURLAUTH_DIGEST') : null;
} elseif ($type === 'ntlm') { } elseif ($type === 'ntlm') {
@ -525,13 +801,6 @@ class StreamHandler
&& $options['curl'][\CURLOPT_HTTPAUTH] === $httpAuth; && $options['curl'][\CURLOPT_HTTPAUTH] === $httpAuth;
} }
private static function usesDigestAuth(array $options): bool
{
return isset($options['auth'][2])
&& \is_string($options['auth'][2])
&& \strtolower($options['auth'][2]) === 'digest';
}
/** /**
* @param mixed $value as passed via Request transfer options. * @param mixed $value as passed via Request transfer options.
* *
@ -583,7 +852,7 @@ class StreamHandler
throw new \InvalidArgumentException(\sprintf('%s must be a non-empty string', $option)); throw new \InvalidArgumentException(\sprintf('%s must be a non-empty string', $option));
} }
if (\strtoupper($value) !== 'PEM') { if (Psr7\Utils::asciiToUpper($value) !== 'PEM') {
throw new \InvalidArgumentException(\sprintf('The stream handler only supports "PEM" for the %s request option.', $option)); throw new \InvalidArgumentException(\sprintf('The stream handler only supports "PEM" for the %s request option.', $option));
} }
} }
@ -591,7 +860,7 @@ class StreamHandler
/** /**
* @param mixed $value as passed via Request transfer options. * @param mixed $value as passed via Request transfer options.
*/ */
private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): void private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): bool
{ {
$uri = null; $uri = null;
@ -610,18 +879,40 @@ class StreamHandler
} }
if (!$uri) { if (!$uri) {
return; return false;
} }
$parsed = $this->parse_proxy($uri); $parsed = $this->parse_proxy($uri);
// PHP extracts and removes only one Proxy-Authorization line for a
// CONNECT tunnel. Serialize exactly one validated first-class value;
// more than one could leave a credential in the tunneled origin
// request. A first-class value, including an empty one, is
// authoritative over Basic credentials embedded in the proxy URI.
$managed = $request->getHeader('Proxy-Authorization');
if (\count($managed) > 1) {
throw new \InvalidArgumentException('The stream handler supports exactly one Proxy-Authorization request header value when a proxy is selected.');
}
if ($managed !== [] && \strpbrk($managed[0], "\r\n") !== false) {
throw new \InvalidArgumentException('Proxy-Authorization request header values must not contain a carriage return or line feed.');
}
$options['http']['proxy'] = $parsed['proxy']; $options['http']['proxy'] = $parsed['proxy'];
if ($parsed['auth']) { if (($managed !== [] || $parsed['auth']) && !isset($options['http']['header'])) {
if (!isset($options['http']['header'])) { $options['http']['header'] = '';
$options['http']['header'] = [];
} }
if ($managed !== []) {
$options['http']['header'] .= "\r\nProxy-Authorization: {$managed[0]}";
return true;
} elseif ($parsed['auth']) {
$options['http']['header'] .= "\r\nProxy-Authorization: {$parsed['auth']}"; $options['http']['header'] .= "\r\nProxy-Authorization: {$parsed['auth']}";
return true;
} }
return false;
} }
/** /**
@ -631,16 +922,29 @@ class StreamHandler
{ {
$parsed = \parse_url($url); $parsed = \parse_url($url);
if ($parsed !== false && isset($parsed['scheme']) && $parsed['scheme'] === 'http') { // parse_url() misreads scheme-less proxy authorities like
if (isset($parsed['host']) && isset($parsed['port'])) { // "user:pass@host"; re-parse only those forms as HTTP.
$auth = null; $schemeLessAuthority = \strpos($url, '://') === false && \strncmp($url, '//', 2) !== 0;
if (isset($parsed['user']) && isset($parsed['pass'])) { if ($schemeLessAuthority) {
$auth = \base64_encode("{$parsed['user']}:{$parsed['pass']}"); if (\is_array($parsed) && !isset($parsed['scheme']) && isset($parsed['host'], $parsed['port'])) {
$parsed['scheme'] = 'http';
} elseif (
(!\is_array($parsed) || !isset($parsed['host']))
&& (\strpos($url, '@') !== false || \strncmp($url, '[', 1) === 0)
) {
$parsed = \parse_url('http://'.$url);
} }
}
if (\is_array($parsed) && isset($parsed['scheme']) && Psr7\Utils::caselessEquals($parsed['scheme'], 'http')) {
if (isset($parsed['host'], $parsed['port'])) {
$user = $parsed['user'] ?? '';
$pass = $parsed['pass'] ?? '';
$auth = ($user !== '' || $pass !== '') ? 'Basic '.\base64_encode("{$user}:{$pass}") : null;
return [ return [
'proxy' => "tcp://{$parsed['host']}:{$parsed['port']}", 'proxy' => "tcp://{$parsed['host']}:{$parsed['port']}",
'auth' => $auth ? "Basic {$auth}" : null, 'auth' => $auth,
]; ];
} }
} }
@ -681,6 +985,26 @@ class StreamHandler
throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided'); throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided');
} }
/**
* @param mixed $value as passed via Request transfer options.
*/
private function add_crypto_method_max(RequestInterface $request, array &$options, $value, array &$params): void
{
$options['ssl']['max_proto_version'] = TlsVersion::streamProtocolVersion('crypto_method_max', $value);
}
private static function assertTlsVersionRangeForOptions(array $options): void
{
if (!isset($options['crypto_method_max'])) {
return;
}
TlsVersion::assertRange(
$options['crypto_method'] ?? null,
$options['crypto_method_max']
);
}
/** /**
* @param mixed $value as passed via Request transfer options. * @param mixed $value as passed via Request transfer options.
*/ */

View file

@ -0,0 +1,84 @@
<?php
namespace GuzzleHttp\Handler;
/**
* @internal
*/
final class TlsVersion
{
/**
* @param mixed $value
*/
public static function ordinal(string $option, $value): int
{
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT) {
return 10;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT) {
return 11;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT) {
return 12;
}
if (\defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && $value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) {
return 13;
}
throw new \InvalidArgumentException(\sprintf('Invalid %s request option: unknown version provided', $option));
}
/**
* @param mixed $min
* @param mixed $max
*/
public static function assertRange($min, $max): void
{
if ($min === null || $max === null) {
return;
}
if (self::ordinal('crypto_method_max', $max) < self::ordinal('crypto_method', $min)) {
throw new \InvalidArgumentException('Invalid crypto_method_max request option: maximum TLS version must be greater than or equal to crypto_method');
}
}
/**
* @param mixed $value
*/
public static function streamProtocolVersion(string $option, $value): int
{
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT) {
return self::requireStreamProto('STREAM_CRYPTO_PROTO_TLSv1_0', $option);
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT) {
return self::requireStreamProto('STREAM_CRYPTO_PROTO_TLSv1_1', $option);
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT) {
return self::requireStreamProto('STREAM_CRYPTO_PROTO_TLSv1_2', $option);
}
if (\defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && $value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) {
return self::requireStreamProto('STREAM_CRYPTO_PROTO_TLSv1_3', $option);
}
throw new \InvalidArgumentException(\sprintf('Invalid %s request option: unknown version provided', $option));
}
/**
* Resolves a STREAM_CRYPTO_PROTO_* constant. The ssl.max_proto_version
* context option and these constants were added in PHP 7.3.0 (TLS 1.3 in
* 7.4.0); on older runtimes the option cannot be honored, so reject loudly.
*/
private static function requireStreamProto(string $constant, string $option): int
{
if (\defined($constant)) {
/** @var int */
return \constant($constant);
}
throw new \InvalidArgumentException(\sprintf(
'Invalid %s request option: maximum TLS version control is not supported by your version of PHP',
$option
));
}
}

View file

@ -72,8 +72,7 @@ class MessageFormatter implements MessageFormatterInterface
{ {
$cache = []; $cache = [];
/** @var string */ $result = \preg_replace_callback(
return \preg_replace_callback(
'/{\s*([A-Za-z_\-\.0-9]+)\s*}/', '/{\s*([A-Za-z_\-\.0-9]+)\s*}/',
function (array $matches) use ($request, $response, $error, &$cache) { function (array $matches) use ($request, $response, $error, &$cache) {
if (isset($cache[$matches[1]])) { if (isset($cache[$matches[1]])) {
@ -90,7 +89,7 @@ class MessageFormatter implements MessageFormatterInterface
break; break;
case 'req_headers': case 'req_headers':
$result = \trim($request->getMethod() $result = \trim($request->getMethod()
.' '.$request->getRequestTarget()) .' '.$request->getRequestTarget(), " \n\r\t\0\x0B")
.' HTTP/'.$request->getProtocolVersion()."\r\n" .' HTTP/'.$request->getProtocolVersion()."\r\n"
.$this->headers($request); .$this->headers($request);
break; break;
@ -182,6 +181,12 @@ class MessageFormatter implements MessageFormatterInterface
}, },
$this->template $this->template
); );
if ($result === null) {
throw new \RuntimeException('Unable to format message: '.\preg_last_error_msg());
}
return $result;
} }
/** /**
@ -194,6 +199,6 @@ class MessageFormatter implements MessageFormatterInterface
$result .= $name.': '.\implode(', ', $values)."\r\n"; $result .= $name.': '.\implode(', ', $values)."\r\n";
} }
return \trim($result); return \trim($result, " \n\r\t\0\x0B");
} }
} }

View file

@ -0,0 +1,26 @@
<?php
namespace GuzzleHttp;
/**
* Multiplexing modes for the "multiplex" request option.
*
* NONE disables multiplexing for a whole handler when passed as the
* "multiplex" client configuration option or, when constructing a handler
* directly, as the CurlMultiHandler "multiplex" constructor option. As a
* request option value it guarantees the transfer does not share its
* connection with any concurrent transfer, and is accepted only where that
* guarantee holds.
*/
final class Multiplexing
{
public const NONE = 'none';
public const EAGER = 'eager';
public const WAIT = 'wait';
public const REQUIRE_EAGER = 'require_eager';
public const REQUIRE_WAIT = 'require_wait';
private function __construct()
{
}
}

View file

@ -71,7 +71,7 @@ class Pool implements PromisorInterface
} elseif (\is_callable($rfn)) { } elseif (\is_callable($rfn)) {
yield $key => $rfn($opts); yield $key => $rfn($opts);
} else { } else {
throw new \InvalidArgumentException('Each value yielded by the iterator must be a Psr7\Http\Message\RequestInterface or a callable that returns a promise that fulfills with a Psr7\Message\Http\ResponseInterface object.'); throw new \InvalidArgumentException('Each value yielded by the iterator must be a Psr\Http\Message\RequestInterface or a callable that returns a promise that fulfills with a Psr\Http\Message\ResponseInterface object.');
} }
} }
}; };

View file

@ -3,6 +3,7 @@
namespace GuzzleHttp; namespace GuzzleHttp;
use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\TooManyRedirectsException; use GuzzleHttp\Exception\TooManyRedirectsException;
use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
@ -104,6 +105,10 @@ class RedirectMiddleware
); );
} }
// The caller's delay applies once, before the initial request, not
// before each followed redirect.
unset($options['delay']);
$promise = $this($nextRequest, $options); $promise = $this($nextRequest, $options);
// Add headers to be able to track history of redirects. // Add headers to be able to track history of redirects.
@ -169,11 +174,13 @@ class RedirectMiddleware
if ($statusCode == 303 if ($statusCode == 303
|| ($statusCode <= 302 && !$options['allow_redirects']['strict']) || ($statusCode <= 302 && !$options['allow_redirects']['strict'])
) { ) {
$safeMethods = ['GET', 'HEAD', 'OPTIONS'];
$requestMethod = $request->getMethod(); $requestMethod = $request->getMethod();
$modify['method'] = in_array($requestMethod, $safeMethods) ? $requestMethod : 'GET'; if ($requestMethod !== 'QUERY' || !\in_array($statusCode, [301, 302], true)) {
$modify['method'] = \in_array($requestMethod, ['GET', 'HEAD', 'OPTIONS'], true) ? $requestMethod : 'GET';
$modify['body'] = ''; $modify['body'] = '';
$modify['remove_headers'] = ['Content-Length', 'Transfer-Encoding'];
}
} }
$uri = self::redirectUri($request, $response, $protocols); $uri = self::redirectUri($request, $response, $protocols);
@ -183,7 +190,20 @@ class RedirectMiddleware
} }
$modify['uri'] = $uri; $modify['uri'] = $uri;
// The body only needs to be rewound when the next request reuses it.
if (!isset($modify['body'])) {
try {
Psr7\Message::rewindBody($request); Psr7\Message::rewindBody($request);
} catch (\RuntimeException $e) {
throw new RequestException(
'Redirect failed because the request body could not be rewound: '.$e->getMessage(),
$request,
$response,
$e
);
}
}
// Add the Referer header if it is told to do so and only // Add the Referer header if it is told to do so and only
// add the header if we are not redirecting from https to http. // add the header if we are not redirecting from https to http.

View file

@ -5,7 +5,7 @@ namespace GuzzleHttp;
/** /**
* This class contains a list of built-in Guzzle request options. * This class contains a list of built-in Guzzle request options.
* *
* @see https://github.com/guzzle/guzzle/blob/7.11/docs/request-options.md * @see https://github.com/guzzle/guzzle/blob/7.15/docs/request-options.md
*/ */
final class RequestOptions final class RequestOptions
{ {
@ -20,7 +20,9 @@ final class RequestOptions
* - max: (int, default=5) maximum number of allowed redirects. * - max: (int, default=5) maximum number of allowed redirects.
* - strict: (bool, default=false) Set to true to use strict redirects * - strict: (bool, default=false) Set to true to use strict redirects
* meaning redirect POST requests with POST requests vs. doing what most * meaning redirect POST requests with POST requests vs. doing what most
* browsers do which is redirect POST requests with GET requests * browsers do which is redirect POST requests with GET requests. The
* QUERY method keeps its method and body across non-strict 301 and 302
* redirects, and a 303 redirect is followed with a body-less GET.
* - referer: (bool, default=false) Set to true to enable the Referer * - referer: (bool, default=false) Set to true to enable the Referer
* header. * header.
* - protocols: (non-empty-array<array-key, string>, default=['http', 'https']) * - protocols: (non-empty-array<array-key, string>, default=['http', 'https'])
@ -96,6 +98,23 @@ final class RequestOptions
*/ */
public const CRYPTO_METHOD = 'crypto_method'; public const CRYPTO_METHOD = 'crypto_method';
/**
* crypto_method_max: (int) A value describing the maximum TLS protocol
* version to use.
*
* This setting must be set to one of the
* ``STREAM_CRYPTO_METHOD_TLS*_CLIENT`` constants. On the stream handler,
* PHP 7.3 or higher is required to set a maximum TLS version, and PHP 7.4
* or higher is required to use TLS 1.3. cURL 7.54.0 or higher is required
* in order to specify a maximum TLS version with the cURL handler.
*/
public const CRYPTO_METHOD_MAX = 'crypto_method_max';
/**
* curl: (array) Raw cURL options to apply when using a built-in cURL handler.
*/
public const CURL = 'curl';
/** /**
* debug: (bool|resource) Set to true or set to a PHP stream returned by * debug: (bool|resource) Set to true or set to a PHP stream returned by
* fopen() enable debug output with the HTTP handler used to send a * fopen() enable debug output with the HTTP handler used to send a
@ -183,6 +202,93 @@ final class RequestOptions
*/ */
public const MULTIPART = 'multipart'; public const MULTIPART = 'multipart';
/**
* multiplex: (string) Controls how a request sent through a built-in
* cURL handler relates to shared, multiplexed connections: how an HTTP/2
* request pursues one, or, with Multiplexing::NONE, whether the transfer
* may share its connection at all. When the option is not set,
* multiplexing is left to libcurl: nothing waits, and established
* multiplex-capable connections are still shared. Use
* Multiplexing::EAGER to explicitly never wait for pending connections,
* Multiplexing::WAIT to wait on libcurl-eligible pending connections with
* CURLOPT_PIPEWAIT, normally to the same origin,
* Multiplexing::REQUIRE_EAGER to fail unless a multiplexed protocol is
* guaranteed while dialing eagerly, or Multiplexing::REQUIRE_WAIT for the
* same guarantee while also waiting on pending connections. The required
* modes require a handler that permits actual multiplexing, not merely a
* multiplexed protocol, and are rejected on a Multiplexing::NONE handler.
* The stream handler ignores EAGER and WAIT, and rejects the required
* family; CurlHandler has no multi handle to multiplex over. Explicit
* modes reject deprecated raw cURL options they conflict with: the
* required family cannot be combined with a raw CURLOPT_HTTP_VERSION,
* CURLOPT_URL, or CURLOPT_FOLLOWLOCATION; no explicit mode can be
* combined with a raw CURLOPT_PIPEWAIT on the CurlMultiHandler; and
* Multiplexing::NONE on a CurlMultiHandler that permits multiplexing
* cannot be combined with the raw CURLOPT_HTTP_VERSION, CURLOPT_HTTPAUTH
* (including the "auth" request option's "digest" and "ntlm" modes,
* which set it), CURLOPT_PROXYAUTH, CURLOPT_FOLLOWLOCATION,
* CURLOPT_HTTPHEADER, CURLOPT_ALTSVC, CURLOPT_ALTSVC_CTRL, or
* CURLOPT_PROXYTYPE cURL options. The required family also
* rejects final CURLOPT_HTTPAUTH masks that permit NTLM, which libcurl
* retries over HTTP/1.1. The required family validates its cleartext
* proxy rule against the final cURL configuration, after raw options
* such as CURLOPT_PROXY and CURLOPT_PRE_PROXY are applied; only the
* exact raw CURLOPT_NOPROXY wildcard '*' disables the primary proxy and
* pre-proxy there, and raw host-specific patterns are conservatively
* treated as leaving them active. These rejections are
* configuration-conflict checks, not remote security checks.
*
* Multiplexing::NONE disables multiplexing for a whole handler when
* passed as the "multiplex" client configuration option, which
* configures the default handler and also becomes the default request
* option, or, when constructing a handler directly, as the
* CurlMultiHandler "multiplex" constructor option. A handler
* configured with Multiplexing::NONE rejects explicitly requested wait
* modes as a configuration conflict when the transfer would actually
* wait, and always rejects the required modes, because they require a
* handler that permits actual multiplexing, not merely a multiplexed
* protocol. As a request option value, Multiplexing::NONE guarantees the
* transfer does not share its connection with any concurrent transfer.
* Multiplexing::NONE does not force HTTP/1.1: on a Multiplexing::NONE
* handler, HTTP/2 still negotiates and each transfer keeps its
* connection to itself.
*
* The request option value is accepted exactly where the guarantee
* holds and can be verified: on a CurlMultiHandler configured with
* Multiplexing::NONE, for requests whose declared protocol version is
* HTTP/1.x, on CurlHandler, and on the stream handler, which never
* multiplexes. An HTTP/2 request with a Multiplexing::NONE request
* option is rejected on a CurlMultiHandler that permits multiplexing.
* On a CurlMultiHandler that permits multiplexing, Multiplexing::NONE
* is also rejected with a custom "handle_factory", alongside a raw
* CURLMOPT_PIPELINING cURL multi option, and combined with the raw
* CURLOPT_HTTP_VERSION, CURLOPT_HTTPAUTH (including the "auth" request
* option's "digest" and "ntlm" modes, which set it), CURLOPT_PROXYAUTH,
* CURLOPT_FOLLOWLOCATION, CURLOPT_HTTPHEADER, CURLOPT_ALTSVC,
* CURLOPT_ALTSVC_CTRL, or CURLOPT_PROXYTYPE cURL options. It is also
* rejected when the request carries an Expect: 100-continue header (its
* 417 retries select connections outside the safeguards; remove an
* explicitly supplied header, or set the "expect" request option to
* false to prevent it being added automatically).
*
* On a client whose multi handler permits multiplexing, the ordinary
* non-streaming default stack - both cURL handlers available and no
* connection caps forcing multi-only routing - runs synchronous
* requests on the CurlHandler path, which satisfies the guarantee for
* any protocol version, while asynchronous requests run on the
* CurlMultiHandler, so an HTTP/2 request with Multiplexing::NONE
* succeeds synchronously and is rejected asynchronously on the same
* client. Keep-alive reuse between consecutive transfers is
* unaffected, except on libcurl versions below 7.77.0 and from 8.11.0
* through 8.12.1, where an accepted HTTP/1.x request on a multiplexing
* CurlMultiHandler forces a fresh connection. Custom handlers receive
* the "multiplex" option unchanged: its semantics are handler-defined,
* Guzzle does not guarantee it is honored, and a client-level
* Multiplexing::NONE with a custom handler flows to it as a default
* request option without client-side enforcement.
*/
public const MULTIPLEX = 'multiplex';
/** /**
* on_headers: (callable) A callable that is invoked when the HTTP headers * on_headers: (callable) A callable that is invoked when the HTTP headers
* of the response have been received but the body has not yet begun to * of the response have been received but the body has not yet begun to
@ -201,6 +307,17 @@ final class RequestOptions
*/ */
public const ON_STATS = 'on_stats'; public const ON_STATS = 'on_stats';
/**
* on_trailers: (callable) A callable that is invoked by the built-in cURL
* handlers once per successful transfer, after the response body has been
* received, with an associative array of the parsed HTTP trailers followed
* by the response. Trailer field names are lowercased and grouped
* case-insensitively; values keep their wire order. Malformed trailer
* field lines are discarded before parsing. Trailer fields are reported
* separately from response headers and are never merged into the response.
*/
public const ON_TRAILERS = 'on_trailers';
/** /**
* progress: (callable) Defines a function to invoke when transfer * progress: (callable) Defines a function to invoke when transfer
* progress is made. The function accepts the following positional * progress is made. The function accepts the following positional
@ -270,6 +387,12 @@ final class RequestOptions
*/ */
public const STREAM = 'stream'; public const STREAM = 'stream';
/**
* stream_context: (array) PHP stream context options to merge into the
* context used by the built-in stream handler.
*/
public const STREAM_CONTEXT = 'stream_context';
/** /**
* verify: (bool|string, default=true) Describes the SSL certificate * verify: (bool|string, default=true) Describes the SSL certificate
* verification behavior of a request. Set to true to enable SSL * verification behavior of a request. Set to true to enable SSL
@ -292,6 +415,11 @@ final class RequestOptions
*/ */
public const READ_TIMEOUT = 'read_timeout'; public const READ_TIMEOUT = 'read_timeout';
/**
* retries: (int) Current retry count used by the retry middleware.
*/
public const RETRIES = 'retries';
/** /**
* version: (string|int|float) Specifies the HTTP protocol version to attempt * version: (string|int|float) Specifies the HTTP protocol version to attempt
* to use. * to use.

View file

@ -6,6 +6,7 @@ use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Handler\CurlHandler; use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\Handler\CurlMultiHandler; use GuzzleHttp\Handler\CurlMultiHandler;
use GuzzleHttp\Handler\CurlShareHandleState; use GuzzleHttp\Handler\CurlShareHandleState;
use GuzzleHttp\Handler\CurlVersion;
use GuzzleHttp\Handler\Proxy; use GuzzleHttp\Handler\Proxy;
use GuzzleHttp\Handler\StreamHandler; use GuzzleHttp\Handler\StreamHandler;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
@ -20,9 +21,18 @@ final class Utils
* *
* @return string Returns a string containing the type of the variable and * @return string Returns a string containing the type of the variable and
* if a class is provided, the class name. * if a class is provided, the class name.
*
* @deprecated Utils::describeType() will be removed in guzzlehttp/guzzle:8.0. Use get_debug_type() instead.
*/ */
public static function describeType($input): string public static function describeType($input): string
{ {
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.12',
'%s() is deprecated and will be removed in 8.0. Use get_debug_type() instead.',
__METHOD__
);
switch (\gettype($input)) { switch (\gettype($input)) {
case 'object': case 'object':
return 'object('.\get_class($input).')'; return 'object('.\get_class($input).')';
@ -35,7 +45,7 @@ final class Utils
/** @var string $varDumpContent */ /** @var string $varDumpContent */
$varDumpContent = \ob_get_clean(); $varDumpContent = \ob_get_clean();
return \str_replace('double(', 'float(', \rtrim($varDumpContent)); return \str_replace('double(', 'float(', \rtrim($varDumpContent, " \n\r\t\0\x0B"));
} }
} }
@ -51,7 +61,7 @@ final class Utils
foreach ($lines as $line) { foreach ($lines as $line) {
$parts = \explode(':', $line, 2); $parts = \explode(':', $line, 2);
$headers[\trim($parts[0])][] = isset($parts[1]) ? \trim($parts[1]) : null; $headers[\trim($parts[0], " \n\r\t\0\x0B")][] = isset($parts[1]) ? \trim($parts[1], " \n\r\t\0\x0B") : null;
} }
return $headers; return $headers;
@ -81,7 +91,7 @@ final class Utils
* *
* The returned handler is not wrapped by any default middlewares. * The returned handler is not wrapped by any default middlewares.
* *
* @param array{transport_sharing?: mixed} $handlerOptions Handler constructor options. * @param array{transport_sharing?: mixed, max_host_connections?: mixed, max_total_connections?: mixed, multiplex?: mixed} $handlerOptions Handler constructor options.
* *
* @return callable(RequestInterface, array): Promise\PromiseInterface Returns the best handler for the given system. * @return callable(RequestInterface, array): Promise\PromiseInterface Returns the best handler for the given system.
* *
@ -89,64 +99,148 @@ final class Utils
*/ */
public static function chooseHandler(array $handlerOptions = []): callable public static function chooseHandler(array $handlerOptions = []): callable
{ {
$handler = null;
$sharingMode = CurlShareHandleState::normalizeMode($handlerOptions['transport_sharing'] ?? null, 'transport_sharing'); $sharingMode = CurlShareHandleState::normalizeMode($handlerOptions['transport_sharing'] ?? null, 'transport_sharing');
$sharingRequested = $sharingMode !== TransportSharing::NONE; $sharingRequired = self::isTransportSharingRequired($sharingMode);
$sharingRequired = $sharingMode === TransportSharing::HANDLER_REQUIRE; $connectionCapsRequired = self::hasConnectionCapOptions($handlerOptions);
$curlHandlerOptions = []; $handler = self::createCurlHandler($sharingMode, $handlerOptions);
$curlSupported = \defined('CURLOPT_CUSTOMREQUEST')
&& \function_exists('curl_version')
&& version_compare(curl_version()['version'], '7.21.2') >= 0
&& (\function_exists('curl_multi_exec') || \function_exists('curl_exec'));
if ($sharingRequired && !$curlSupported) { if ($sharingRequired && $handler === null) {
throw new \RuntimeException('Required transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and libcurl 7.21.2 or higher.'); throw new \RuntimeException('Required transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and libcurl 7.21.2 or higher.');
} }
if ($curlSupported) {
if ($sharingRequested) {
$shareState = CurlShareHandleState::fromOption($sharingMode);
if ($shareState !== null) {
$curlHandlerOptions['transport_sharing'] = $shareState;
}
}
if (\function_exists('curl_multi_exec') && \function_exists('curl_exec')) {
$handler = Proxy::wrapSync(new CurlMultiHandler($curlHandlerOptions), new CurlHandler($curlHandlerOptions));
} elseif (\function_exists('curl_exec')) {
$handler = new CurlHandler($curlHandlerOptions);
} elseif (\function_exists('curl_multi_exec')) {
$handler = new CurlMultiHandler($curlHandlerOptions);
}
}
if (\ini_get('allow_url_fopen')) { if (\ini_get('allow_url_fopen')) {
$streamHandler = new StreamHandler(); return self::addStreamHandler($handler, $sharingMode, $sharingRequired, self::connectionCapOptions($handlerOptions));
if ($sharingRequired) {
$streamHandler = self::wrapStreamHandlerTransportSharing($streamHandler, $sharingMode);
}
$handler = $handler
? Proxy::wrapStreaming($handler, $streamHandler)
: $streamHandler;
} elseif (!$handler) {
throw new \RuntimeException('GuzzleHttp requires cURL, the allow_url_fopen ini setting, or a custom HTTP handler.');
} }
if ($handler !== null) {
return $handler; return $handler;
} }
private static function wrapStreamHandlerTransportSharing(callable $handler, string $sharingMode): callable if ($connectionCapsRequired) {
{ throw new \RuntimeException('Connection cap options require a cap-capable cURL multi handler or the allow_url_fopen ini setting for stream fallback.');
return static function (RequestInterface $request, array $options) use ($handler, $sharingMode): Promise\PromiseInterface {
if (\array_key_exists('transport_sharing', $options)) {
CurlShareHandleState::normalizeMode($options['transport_sharing'], 'transport_sharing');
} }
$options['transport_sharing'] = $sharingMode; throw new \RuntimeException('GuzzleHttp requires cURL, the allow_url_fopen ini setting, or a custom HTTP handler.');
}
return $handler($request, $options); private static function isTransportSharingRequired(string $sharingMode): bool
}; {
return $sharingMode === TransportSharing::HANDLER_REQUIRE;
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed} $handlerOptions
*/
private static function hasConnectionCapOptions(array $handlerOptions): bool
{
return self::connectionCapOptions($handlerOptions) !== [];
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed, multiplex?: mixed} $handlerOptions
*
* @return (callable(RequestInterface, array): Promise\PromiseInterface)|null
*/
private static function createCurlHandler(string $sharingMode, array $handlerOptions): ?callable
{
if (!\defined('CURLOPT_CUSTOMREQUEST') || !CurlVersion::supportsCurlHandler()) {
return null;
}
$connectionCapOptions = self::connectionCapOptions($handlerOptions);
if ($connectionCapOptions !== [] && (!CurlVersion::supportsConnectionCaps() || !\function_exists('curl_multi_exec'))) {
return null;
}
$curlHandlerOptions = self::createCurlHandlerOptions($sharingMode);
$curlMultiHandlerOptions = $curlHandlerOptions + $connectionCapOptions;
if (($handlerOptions['multiplex'] ?? null) === Multiplexing::NONE) {
// Forwarded to the CurlMultiHandler only: CurlHandler and
// StreamHandler validate known options, and both satisfy NONE
// per-request without a handler option.
$curlMultiHandlerOptions['multiplex'] = Multiplexing::NONE;
}
if (\function_exists('curl_multi_exec') && \function_exists('curl_exec')) {
$multiHandler = new CurlMultiHandler($curlMultiHandlerOptions);
if ($connectionCapOptions !== []) {
// Connection caps only govern transfers on the multi handle, so
// the synchronous CurlHandler fast path would escape them.
return $multiHandler;
}
return Proxy::wrapSync($multiHandler, new CurlHandler($curlHandlerOptions));
}
if ($connectionCapOptions === [] && \function_exists('curl_exec')) {
return new CurlHandler($curlHandlerOptions);
}
if (\function_exists('curl_multi_exec')) {
return new CurlMultiHandler($curlMultiHandlerOptions);
}
return null;
}
/**
* @return array<string, mixed>
*/
private static function createCurlHandlerOptions(string $sharingMode): array
{
if ($sharingMode === TransportSharing::NONE) {
return [];
}
$shareState = CurlShareHandleState::fromOption($sharingMode);
return $shareState === null ? [] : ['transport_sharing' => $shareState];
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed} $handlerOptions
*
* @return array{max_host_connections?: int, max_total_connections?: int}
*/
private static function connectionCapOptions(array $handlerOptions): array
{
$options = [];
foreach (['max_host_connections', 'max_total_connections'] as $capOption) {
$value = $handlerOptions[$capOption] ?? null;
if ($value === null) {
continue;
}
if (!\is_int($value) || $value < 1) {
throw new InvalidArgumentException(\sprintf('%s must be a positive integer.', $capOption));
}
$options[$capOption] = $value;
}
return $options;
}
/**
* @param (callable(RequestInterface, array): Promise\PromiseInterface)|null $handler
* @param array{max_host_connections?: int, max_total_connections?: int} $connectionCapOptions
*
* @return callable(RequestInterface, array): Promise\PromiseInterface
*/
private static function addStreamHandler(?callable $handler, string $sharingMode, bool $sharingRequired, array $connectionCapOptions): callable
{
$streamHandler = new StreamHandler(['transport_sharing' => $sharingMode] + $connectionCapOptions);
if ($handler === null) {
return $streamHandler;
}
if (!$sharingRequired) {
$handler = Proxy::wrapTlsFallback($handler, $streamHandler);
}
return Proxy::wrapStreaming($handler, $streamHandler);
} }
/** /**
@ -174,6 +268,8 @@ final class Utils
*/ */
public static function defaultCaBundle(): string public static function defaultCaBundle(): string
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. This method is not needed in PHP 5.6+.', __METHOD__);
static $cached = null; static $cached = null;
static $cafiles = [ static $cafiles = [
// Red Hat, CentOS, Fedora (provided by the ca-certificates package) // Red Hat, CentOS, Fedora (provided by the ca-certificates package)
@ -216,14 +312,14 @@ final class Utils
No system CA bundle could be found in any of the the common system locations. No system CA bundle could be found in any of the the common system locations.
PHP versions earlier than 5.6 are not properly configured to use the system's PHP versions earlier than 5.6 are not properly configured to use the system's
CA bundle by default. In order to verify peer certificates, you will need to CA bundle by default. In order to verify peer certificates, you will need to
supply the path on disk to a certificate bundle to the 'verify' request supply the path on disk to a certificate bundle to the 'verify' request option:
option: https://github.com/guzzle/guzzle/blob/7.11/docs/request-options.md#verify. If https://github.com/guzzle/guzzle/blob/7.15/docs/request-options.md#verify. If
you do not need a specific certificate bundle, then Mozilla provides a commonly you do not need a specific certificate bundle, then Mozilla provides a commonly
used CA bundle which can be downloaded here (provided by the maintainer of used CA bundle which can be downloaded here (provided by the maintainer of
cURL): https://curl.haxx.se/ca/cacert.pem. Once you have a CA bundle available cURL): https://curl.se/ca/cacert.pem. Once you have a CA bundle available on
on disk, you can set the 'openssl.cafile' PHP ini setting to point to the path disk, you can set the 'openssl.cafile' PHP ini setting to point to the path to
to the file, allowing you to omit the 'verify' request option. See the file, allowing you to omit the 'verify' request option. See
https://curl.haxx.se/docs/sslcerts.html for more information. https://curl.se/docs/sslcerts.html for more information.
EOT EOT
); );
} }
@ -236,7 +332,7 @@ EOT
{ {
$result = []; $result = [];
foreach (\array_keys($headers) as $key) { foreach (\array_keys($headers) as $key) {
$result[\strtolower((string) $key)] = $key; $result[Psr7\Utils::asciiToLower((string) $key)] = $key;
} }
return $result; return $result;
@ -275,19 +371,22 @@ EOT
/** /**
* Returns true if the provided host matches any of the no proxy areas. * Returns true if the provided host matches any of the no proxy areas.
* *
* This method will strip a port from the host if it is present. Each pattern * This method will strip a port from the host if it is present. Domain
* can be matched with an exact match (e.g., "foo.com" == "foo.com") or a * patterns are matched case-insensitively. Exact IP literal patterns are
* partial match: (e.g., "foo.com" == "baz.foo.com" and ".foo.com" == * matched by their normalized binary address.
* "baz.foo.com", but ".foo.com" != "foo.com").
* *
* Areas are matched in the following cases: * Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts. * 1. "*" (without quotes) always matches any hosts.
* 2. An exact match. * 2. An exact domain or IP literal match.
* 3. The area starts with "." and the area is the last part of the host. e.g. * 3. A bare domain matches itself and its subdomains. e.g. 'mit.edu' will
* match 'mit.edu' and 'foo.mit.edu'.
* 4. The area starts with "." and the area is the last part of the host. e.g.
* '.mit.edu' will match any host that ends with '.mit.edu'. * '.mit.edu' will match any host that ends with '.mit.edu'.
* 5. IP CIDR entries match IP literal hosts. e.g. '192.168.0.0/16' will
* match '192.168.1.10' and 'fd00::/8' will match '[fd00::1]'.
* *
* @param string $host Host to check against the patterns. * @param string $host Host to check against the patterns.
* @param string[] $noProxyArray An array of host patterns. * @param string[] $noProxyArray An array of host or CIDR patterns.
* *
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
@ -297,43 +396,23 @@ EOT
throw new InvalidArgumentException('Empty host provided'); throw new InvalidArgumentException('Empty host provided');
} }
$host = self::normalizeNoProxyHost($host, true); $target = self::parseNoProxyHostString($host);
if ($target === null) {
foreach ($noProxyArray as $area) {
// Always match on wildcards.
if ($area === '*') {
return true;
}
if ($area === '') {
continue;
}
$area = self::normalizeNoProxyHost($area, false);
if ($area === $host) {
// Exact matches.
return true;
}
// Special match if the area when prefixed with ".". Remove any
// existing leading "." and add a new leading ".".
$area = '.'.\ltrim($area, '.');
if (
\strpos($host, ':') === false
&& \strpos($area, ':') === false
&& \substr($host, -\strlen($area)) === $area
) {
return true;
}
}
return false; return false;
} }
return self::matchesNoProxyList($target, $noProxyArray);
}
/** /**
* Returns true if the provided URI matches any of the no proxy areas. * Returns true if the provided URI matches any of the no proxy areas.
* *
* @param mixed $noProxy No-proxy host patterns. * Matching follows the same rules as isHostInNoProxy(), with the
* addition that areas may carry a port (e.g. "example.com:8080" or
* "[::1]:8080") which is compared against the URI port (or the scheme
* default port when the URI has none).
*
* @param mixed $noProxy No-proxy host, host-and-port, or CIDR patterns.
* *
* @internal * @internal
*/ */
@ -347,38 +426,34 @@ EOT
return false; return false;
} }
$host = $uri->getHost(); $target = self::parseNoProxyTarget($uri);
if ($host === '') { if ($target === null) {
return false; return false;
} }
$port = $uri->getPort(); return self::matchesNoProxyList($target, $noProxy);
if ($port === null) {
$port = self::getDefaultPort($uri->getScheme());
} }
/**
* @param array{type: string, value: string, port: int|null, matchesRoot: bool} $target
* @param array<array-key, mixed> $noProxy
*/
private static function matchesNoProxyList(array $target, array $noProxy): bool
{
foreach ($noProxy as $area) { foreach ($noProxy as $area) {
if (!\is_string($area)) { if (!\is_string($area)) {
continue; continue;
} }
$area = \trim($area); $area = \trim($area, " \n\r\t\0\x0B");
// Always match on wildcards. // Always match on wildcards.
if ($area === '*') { if ($area === '*') {
return true; return true;
} }
if ($area === '') { $rule = self::parseNoProxyRule($area);
continue; if ($rule !== null && self::noProxyRuleMatches($target, $rule)) {
}
[$area, $areaPort] = self::splitNoProxyHostAndPort($area);
if ($areaPort !== null && $areaPort !== $port) {
continue;
}
if (self::isHostInNoProxy($host, [$area])) {
return true; return true;
} }
} }
@ -386,58 +461,157 @@ EOT
return false; return false;
} }
private static function normalizeNoProxyHost(string $host, bool $stripPort): string /**
* @return array{type: string, value: string, port: int|null, matchesRoot: bool}|null
*/
private static function parseNoProxyTarget(UriInterface $uri): ?array
{ {
if ($host !== '' && $host[0] === '[') { $host = $uri->getHost();
$closingBracket = \strpos($host, ']'); if ($host === '') {
return null;
if ($closingBracket !== false) {
$address = \substr($host, 1, $closingBracket - 1);
$tail = \substr($host, $closingBracket + 1);
if (
($tail === '' || ($stripPort && \preg_match('/^:\d+$/', $tail)))
&& \filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)
) {
return \strtolower($address);
}
}
} }
if (\filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { return self::parseNoProxyHost($host, $uri->getPort() ?? self::getDefaultPort($uri->getScheme()), true);
return \strtolower($host);
}
if ($stripPort) {
[$host] = \explode(':', $host, 2);
}
return $host;
} }
/** /**
* @return array{0: string, 1: int|null} * @return array{type: string, value: string, port: int|null, matchesRoot: bool}|null
*/ */
private static function splitNoProxyHostAndPort(string $area): array private static function parseNoProxyHostString(string $host): ?array
{
$hostAndPort = self::splitNoProxyHostAndPort($host);
if ($hostAndPort === null) {
return null;
}
[$host] = $hostAndPort;
return self::parseNoProxyHost($host, null, true);
}
/**
* @return array{type: string, value: string, port: int|null, matchesRoot: bool}|array{type: string, value: string, prefix: int}|null
*/
private static function parseNoProxyRule(string $area): ?array
{
$area = \trim($area, " \n\r\t\0\x0B");
if ($area === '' || $area === '*') {
return null;
}
if (\strpos($area, '/') !== false) {
return self::parseNoProxyCidrRule($area);
}
$matchesRoot = true;
if ($area[0] === '.') {
$matchesRoot = false;
$area = \substr($area, 1);
}
$hostAndPort = self::splitNoProxyHostAndPort($area);
if ($hostAndPort === null) {
return null;
}
[$host, $port] = $hostAndPort;
if ($host === '*') {
if (!$matchesRoot) {
return null;
}
return [
'type' => 'wildcard',
'value' => '*',
'port' => $port,
'matchesRoot' => true,
];
}
$rule = self::parseNoProxyHost($host, $port, $matchesRoot);
if ($rule !== null && !$matchesRoot && $rule['type'] === 'ip') {
return null;
}
return $rule;
}
/**
* @return array{type: string, value: string, port: int|null, matchesRoot: bool}|null
*/
private static function parseNoProxyHost(string $host, ?int $port, bool $matchesRoot): ?array
{
if ($host !== '' && $host[0] === '[') {
if (\substr($host, -1) !== ']') {
return null;
}
$address = \substr($host, 1, -1);
if (!\filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return null;
}
$host = $address;
}
$packedIp = self::packIpAddress($host);
if ($packedIp !== false) {
return [
'type' => 'ip',
'value' => $packedIp,
'port' => $port,
'matchesRoot' => $matchesRoot,
];
}
if ($host === '' || \strpos($host, ':') !== false) {
return null;
}
// Normalize a single DNS root dot for no-proxy domain matching.
if (\substr($host, -1) === '.') {
$host = \substr($host, 0, -1);
if ($host === '') {
return null;
}
}
return [
'type' => 'domain',
'value' => Psr7\Utils::asciiToLower($host),
'port' => $port,
'matchesRoot' => $matchesRoot,
];
}
/**
* @return array{0: string, 1: int|null}|null
*/
private static function splitNoProxyHostAndPort(string $area): ?array
{ {
if ($area !== '' && $area[0] === '[') { if ($area !== '' && $area[0] === '[') {
$closingBracket = \strpos($area, ']'); $closingBracket = \strpos($area, ']');
if ($closingBracket === false) {
return null;
}
if ($closingBracket !== false) { $host = \substr($area, 0, $closingBracket + 1);
$tail = \substr($area, $closingBracket + 1); $tail = \substr($area, $closingBracket + 1);
if ($tail !== '' && $tail[0] === ':') { if ($tail === '') {
return [$host, null];
}
if ($tail[0] !== ':') {
return null;
}
$port = self::parseNoProxyPort(\substr($tail, 1)); $port = self::parseNoProxyPort(\substr($tail, 1));
if ($port !== null) { return $port === null ? null : [$host, $port];
return [\substr($area, 0, $closingBracket + 1), $port];
}
}
} }
return [$area, null]; if (self::packIpAddress($area) !== false) {
}
if (\filter_var($area, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return [$area, null]; return [$area, null];
} }
@ -448,7 +622,7 @@ EOT
$port = self::parseNoProxyPort(\substr($area, $colon + 1)); $port = self::parseNoProxyPort(\substr($area, $colon + 1));
if ($port === null) { if ($port === null) {
return [$area, null]; return null;
} }
return [\substr($area, 0, $colon), $port]; return [\substr($area, 0, $colon), $port];
@ -456,13 +630,131 @@ EOT
private static function parseNoProxyPort(string $port): ?int private static function parseNoProxyPort(string $port): ?int
{ {
if ($port === '' || !\ctype_digit($port)) { return self::parseBoundedUnsignedInteger($port, 65535);
}
/**
* @return array{type: string, value: string, prefix: int}|null
*/
private static function parseNoProxyCidrRule(string $area): ?array
{
$slash = \strpos($area, '/');
if ($slash === false) {
return null; return null;
} }
$port = (int) $port; $prefix = \substr($area, $slash + 1);
return $port <= 65535 ? $port : null; $network = \substr($area, 0, $slash);
if ($network !== '' && $network[0] === '[' && \substr($network, -1) === ']') {
$network = \substr($network, 1, -1);
}
$network = self::packIpAddress($network);
if ($network === false) {
return null;
}
$prefix = self::parseBoundedUnsignedInteger($prefix, \strlen($network) * 8);
if ($prefix === null) {
return null;
}
return [
'type' => 'cidr',
'value' => $network,
'prefix' => $prefix,
];
}
private static function parseBoundedUnsignedInteger(string $value, int $max): ?int
{
if ($value === '' || !\ctype_digit($value)) {
return null;
}
$normalized = \ltrim($value, '0');
$normalized = $normalized === '' ? '0' : $normalized;
$limit = (string) $max;
if (\strlen($normalized) > \strlen($limit) || (\strlen($normalized) === \strlen($limit) && \strcmp($normalized, $limit) > 0)) {
return null;
}
return (int) $normalized;
}
/**
* @param array{type: string, value: string, port: int|null, matchesRoot: bool} $target
* @param array{type: string, value: string, port?: int|null, matchesRoot?: bool, prefix?: int|null} $rule
*/
private static function noProxyRuleMatches(array $target, array $rule): bool
{
if ($rule['type'] === 'wildcard') {
return ($rule['port'] ?? null) === null || $rule['port'] === $target['port'];
}
if ($rule['type'] === 'cidr') {
if ($target['type'] !== 'ip' || !isset($rule['prefix'])) {
return false;
}
if (\strlen($target['value']) !== \strlen($rule['value'])) {
return false;
}
return self::ipMatchesPrefix($target['value'], $rule['value'], $rule['prefix']);
}
if (($rule['port'] ?? null) !== null && $rule['port'] !== $target['port']) {
return false;
}
if ($rule['type'] !== $target['type']) {
return false;
}
if ($rule['type'] === 'ip') {
return $rule['value'] === $target['value'];
}
if (($rule['matchesRoot'] ?? false) && $target['value'] === $rule['value']) {
return true;
}
$suffix = '.'.$rule['value'];
return \substr($target['value'], -\strlen($suffix)) === $suffix;
}
/**
* @return string|false
*/
private static function packIpAddress(string $ip)
{
if (!\filter_var($ip, \FILTER_VALIDATE_IP)) {
return false;
}
return \inet_pton($ip);
}
private static function ipMatchesPrefix(string $address, string $network, int $prefix): bool
{
$fullBytes = \intdiv($prefix, 8);
$remainingBits = $prefix % 8;
if ($fullBytes > 0 && \substr($address, 0, $fullBytes) !== \substr($network, 0, $fullBytes)) {
return false;
}
if ($remainingBits === 0) {
return true;
}
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
return (\ord($address[$fullBytes]) & $mask) === (\ord($network[$fullBytes]) & $mask);
} }
private static function getDefaultPort(string $scheme): ?int private static function getDefaultPort(string $scheme): ?int
@ -492,9 +784,12 @@ EOT
* @throws InvalidArgumentException if the JSON cannot be decoded. * @throws InvalidArgumentException if the JSON cannot be decoded.
* *
* @see https://www.php.net/manual/en/function.json-decode.php * @see https://www.php.net/manual/en/function.json-decode.php
* @deprecated Utils::jsonDecode() will be removed in guzzlehttp/guzzle:8.0. Use PHP's json_decode() instead.
*/ */
public static function jsonDecode(string $json, bool $assoc = false, int $depth = 512, int $options = 0) public static function jsonDecode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.15', '%s() is deprecated and will be removed in 8.0. Use PHP\'s json_decode() instead.', __METHOD__);
if ($depth < 1) { if ($depth < 1) {
throw new InvalidArgumentException('json_decode error: Maximum stack depth exceeded'); throw new InvalidArgumentException('json_decode error: Maximum stack depth exceeded');
} }
@ -517,9 +812,12 @@ EOT
* @throws InvalidArgumentException if the JSON cannot be encoded. * @throws InvalidArgumentException if the JSON cannot be encoded.
* *
* @see https://www.php.net/manual/en/function.json-encode.php * @see https://www.php.net/manual/en/function.json-encode.php
* @deprecated Utils::jsonEncode() will be removed in guzzlehttp/guzzle:8.0. Use PHP's json_encode() instead.
*/ */
public static function jsonEncode($value, int $options = 0, int $depth = 512): string public static function jsonEncode($value, int $options = 0, int $depth = 512): string
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.15', '%s() is deprecated and will be removed in 8.0. Use PHP\'s json_encode() instead.', __METHOD__);
$json = \json_encode($value, $options, $depth); $json = \json_encode($value, $options, $depth);
if (\JSON_ERROR_NONE !== \json_last_error()) { if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg()); throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg());
@ -566,7 +864,7 @@ EOT
'guzzlehttp/guzzle', 'guzzlehttp/guzzle',
'7.11', '7.11',
'Passing %s as the "idn_conversion" request option is deprecated; guzzlehttp/guzzle 8.0 will reject values that are not true, false, null, or an integer IDNA_* bitmask.', 'Passing %s as the "idn_conversion" request option is deprecated; guzzlehttp/guzzle 8.0 will reject values that are not true, false, null, or an integer IDNA_* bitmask.',
self::describeType($value) \get_debug_type($value)
); );
return (int) $value; return (int) $value;

View file

@ -11,11 +11,26 @@ namespace GuzzleHttp;
* @return string Returns a string containing the type of the variable and * @return string Returns a string containing the type of the variable and
* if a class is provided, the class name. * if a class is provided, the class name.
* *
* @deprecated describe_type will be removed in guzzlehttp/guzzle:8.0. Use Utils::describeType instead. * @deprecated describe_type will be removed in guzzlehttp/guzzle:8.0. Use get_debug_type() instead.
*/ */
function describe_type($input): string function describe_type($input): string
{ {
return Utils::describeType($input); \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use get_debug_type() instead.', __FUNCTION__);
switch (\gettype($input)) {
case 'object':
return 'object('.\get_class($input).')';
case 'array':
return 'array('.\count($input).')';
default:
\ob_start();
\var_dump($input);
// normalize float vs double
/** @var string $varDumpContent */
$varDumpContent = \ob_get_clean();
return \str_replace('double(', 'float(', \rtrim($varDumpContent, " \n\r\t\0\x0B"));
}
} }
/** /**
@ -28,6 +43,8 @@ function describe_type($input): string
*/ */
function headers_from_lines(iterable $lines): array function headers_from_lines(iterable $lines): array
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::headersFromLines() instead.', __FUNCTION__);
return Utils::headersFromLines($lines); return Utils::headersFromLines($lines);
} }
@ -42,6 +59,8 @@ function headers_from_lines(iterable $lines): array
*/ */
function debug_resource($value = null) function debug_resource($value = null)
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::debugResource() instead.', __FUNCTION__);
return Utils::debugResource($value); return Utils::debugResource($value);
} }
@ -58,6 +77,8 @@ function debug_resource($value = null)
*/ */
function choose_handler(): callable function choose_handler(): callable
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::chooseHandler() instead.', __FUNCTION__);
return Utils::chooseHandler(); return Utils::chooseHandler();
} }
@ -68,6 +89,8 @@ function choose_handler(): callable
*/ */
function default_user_agent(): string function default_user_agent(): string
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::defaultUserAgent() instead.', __FUNCTION__);
return Utils::defaultUserAgent(); return Utils::defaultUserAgent();
} }
@ -88,7 +111,60 @@ function default_user_agent(): string
*/ */
function default_ca_bundle(): string function default_ca_bundle(): string
{ {
return Utils::defaultCaBundle(); \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. This function is not needed in PHP 5.6+.', __FUNCTION__);
static $cached = null;
static $cafiles = [
// Red Hat, CentOS, Fedora (provided by the ca-certificates package)
'/etc/pki/tls/certs/ca-bundle.crt',
// Ubuntu, Debian (provided by the ca-certificates package)
'/etc/ssl/certs/ca-certificates.crt',
// FreeBSD (provided by the ca_root_nss package)
'/usr/local/share/certs/ca-root-nss.crt',
// SLES 12 (provided by the ca-certificates package)
'/var/lib/ca-certificates/ca-bundle.pem',
// OS X provided by homebrew (using the default path)
'/usr/local/etc/openssl/cert.pem',
// Google app engine
'/etc/ca-certificates.crt',
// Windows?
'C:\\windows\\system32\\curl-ca-bundle.crt',
'C:\\windows\\curl-ca-bundle.crt',
];
if ($cached) {
return $cached;
}
if ($ca = \ini_get('openssl.cafile')) {
return $cached = $ca;
}
if ($ca = \ini_get('curl.cainfo')) {
return $cached = $ca;
}
foreach ($cafiles as $filename) {
if (\file_exists($filename)) {
return $cached = $filename;
}
}
throw new \RuntimeException(
<<< EOT
No system CA bundle could be found in any of the the common system locations.
PHP versions earlier than 5.6 are not properly configured to use the system's
CA bundle by default. In order to verify peer certificates, you will need to
supply the path on disk to a certificate bundle to the 'verify' request option:
https://github.com/guzzle/guzzle/blob/7.15/docs/request-options.md#verify. If
you do not need a specific certificate bundle, then Mozilla provides a commonly
used CA bundle which can be downloaded here (provided by the maintainer of
cURL): https://curl.se/ca/cacert.pem. Once you have a CA bundle available on
disk, you can set the 'openssl.cafile' PHP ini setting to point to the path to
the file, allowing you to omit the 'verify' request option. See
https://curl.se/docs/sslcerts.html for more information.
EOT
);
} }
/** /**
@ -99,6 +175,8 @@ function default_ca_bundle(): string
*/ */
function normalize_header_keys(array $headers): array function normalize_header_keys(array $headers): array
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::normalizeHeaderKeys() instead.', __FUNCTION__);
return Utils::normalizeHeaderKeys($headers); return Utils::normalizeHeaderKeys($headers);
} }
@ -125,6 +203,8 @@ function normalize_header_keys(array $headers): array
*/ */
function is_host_in_noproxy(string $host, array $noProxyArray): bool function is_host_in_noproxy(string $host, array $noProxyArray): bool
{ {
\trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use Utils::isHostInNoProxy() instead.', __FUNCTION__);
return Utils::isHostInNoProxy($host, $noProxyArray); return Utils::isHostInNoProxy($host, $noProxyArray);
} }
@ -142,11 +222,23 @@ function is_host_in_noproxy(string $host, array $noProxyArray): bool
* @throws Exception\InvalidArgumentException if the JSON cannot be decoded. * @throws Exception\InvalidArgumentException if the JSON cannot be decoded.
* *
* @see https://www.php.net/manual/en/function.json-decode.php * @see https://www.php.net/manual/en/function.json-decode.php
* @deprecated json_decode will be removed in guzzlehttp/guzzle:8.0. Use Utils::jsonDecode instead. * @deprecated json_decode will be removed in guzzlehttp/guzzle:8.0. Use PHP's json_decode() instead.
*/ */
function json_decode(string $json, bool $assoc = false, int $depth = 512, int $options = 0) function json_decode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{ {
return Utils::jsonDecode($json, $assoc, $depth, $options); \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use PHP\'s json_decode() instead.', __FUNCTION__);
if ($depth < 1) {
throw new Exception\InvalidArgumentException('json_decode error: Maximum stack depth exceeded');
}
$data = \json_decode($json, $assoc, $depth, $options);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new Exception\InvalidArgumentException('json_decode error: '.\json_last_error_msg());
}
/** @var object|array|string|int|float|bool|null $data */
return $data;
} }
/** /**
@ -159,9 +251,18 @@ function json_decode(string $json, bool $assoc = false, int $depth = 512, int $o
* @throws Exception\InvalidArgumentException if the JSON cannot be encoded. * @throws Exception\InvalidArgumentException if the JSON cannot be encoded.
* *
* @see https://www.php.net/manual/en/function.json-encode.php * @see https://www.php.net/manual/en/function.json-encode.php
* @deprecated json_encode will be removed in guzzlehttp/guzzle:8.0. Use Utils::jsonEncode instead. * @deprecated json_encode will be removed in guzzlehttp/guzzle:8.0. Use PHP's json_encode() instead.
*/ */
function json_encode($value, int $options = 0, int $depth = 512): string function json_encode($value, int $options = 0, int $depth = 512): string
{ {
return Utils::jsonEncode($value, $options, $depth); \trigger_deprecation('guzzlehttp/guzzle', '7.1', '%s() is deprecated and will be removed in 8.0. Use PHP\'s json_encode() instead.', __FUNCTION__);
/** @var positive-int $depth */
$json = \json_encode($value, $options, $depth);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new Exception\InvalidArgumentException('json_encode error: '.\json_last_error_msg());
}
/** @var non-empty-string $json */
return $json;
} }

View file

@ -1,215 +0,0 @@
# CHANGELOG
## 2.5.0 - 2026-06-02
### Deprecated
- Deprecated passing non-iterable inputs to promise collection helpers and `EachPromise`
## 2.4.1 - 2026-05-20
### Fixed
- Fixed cancelling settled coroutines when no current promise remains
## 2.4.0 - 2026-05-20
### Changed
- Empty `EachPromise` instances now resolve when the task queue runs without `wait()`
## 2.3.1 - 2026-05-19
### Fixed
- Fixed `Utils::inspect()` returning the internal reason array instead of the `AggregateException`
## 2.3.0 - 2025-08-22
### Added
- PHP 8.5 support
## 2.2.0 - 2025-03-27
### Fixed
- Revert "Allow an empty EachPromise to be resolved by running the queue"
## 2.1.0 - 2025-03-27
### Added
- Allow an empty EachPromise to be resolved by running the queue
## 2.0.4 - 2024-10-17
### Fixed
- Once settled, don't allow further rejection of additional promises
## 2.0.3 - 2024-07-18
### Changed
- PHP 8.4 support
## 2.0.2 - 2023-12-03
### Changed
- Replaced `call_user_func*` with native calls
## 2.0.1 - 2023-08-03
### Changed
- PHP 8.3 support
## 2.0.0 - 2023-05-21
### Added
- Added PHP 7 type hints
### Changed
- All previously non-final non-exception classes have been marked as soft-final
### Removed
- Dropped PHP < 7.2 support
- All functions in the `GuzzleHttp\Promise` namespace
## 1.5.3 - 2023-05-21
### Changed
- Removed remaining usage of deprecated functions
## 1.5.2 - 2022-08-07
### Changed
- Officially support PHP 8.2
## 1.5.1 - 2021-10-22
### Fixed
- Revert "Call handler when waiting on fulfilled/rejected Promise"
- Fix pool memory leak when empty array of promises provided
## 1.5.0 - 2021-10-07
### Changed
- Call handler when waiting on fulfilled/rejected Promise
- Officially support PHP 8.1
### Fixed
- Fix manually settle promises generated with `Utils::task`
## 1.4.1 - 2021-02-18
### Fixed
- Fixed `each_limit` skipping promises and failing
## 1.4.0 - 2020-09-30
### Added
- Support for PHP 8
- Optional `$recursive` flag to `all`
- Replaced functions by static methods
### Fixed
- Fix empty `each` processing
- Fix promise handling for Iterators of non-unique keys
- Fixed `method_exists` crashes on PHP 8
- Memory leak on exceptions
## 1.3.1 - 2016-12-20
### Fixed
- `wait()` foreign promise compatibility
## 1.3.0 - 2016-11-18
### Added
- Adds support for custom task queues.
### Fixed
- Fixed coroutine promise memory leak.
## 1.2.0 - 2016-05-18
### Changed
- Update to now catch `\Throwable` on PHP 7+
## 1.1.0 - 2016-03-07
### Changed
- Update EachPromise to prevent recurring on a iterator when advancing, as this
could trigger fatal generator errors.
- Update Promise to allow recursive waiting without unwrapping exceptions.
## 1.0.3 - 2015-10-15
### Changed
- Update EachPromise to immediately resolve when the underlying promise iterator
is empty. Previously, such a promise would throw an exception when its `wait`
function was called.
## 1.0.2 - 2015-05-15
### Changed
- Conditionally require functions.php.
## 1.0.1 - 2015-06-24
### Changed
- Updating EachPromise to call next on the underlying promise iterator as late
as possible to ensure that generators that generate new requests based on
callbacks are not iterated until after callbacks are invoked.
## 1.0.0 - 2015-05-12
- Initial release

View file

@ -1,536 +0,0 @@
# Guzzle Promises
[Promises/A+](https://promisesaplus.com/) implementation that handles promise
chaining and resolution iteratively, allowing for "infinite" promise chaining
while keeping the stack size constant. Read [this blog post](https://blog.domenic.me/youre-missing-the-point-of-promises/)
for a general introduction to promises.
- [Features](#features)
- [Quick start](#quick-start)
- [Synchronous wait](#synchronous-wait)
- [Cancellation](#cancellation)
- [API](#api)
- [Promise](#promise)
- [FulfilledPromise](#fulfilledpromise)
- [RejectedPromise](#rejectedpromise)
- [Promise interop](#promise-interop)
- [Implementation notes](#implementation-notes)
## Features
- [Promises/A+](https://promisesaplus.com/) implementation.
- Promise resolution and chaining is handled iteratively, allowing for
"infinite" promise chaining.
- Promises have a synchronous `wait` method.
- Promises can be cancelled.
- Works with any object that has a `then` function.
- C# style async/await coroutine promises using
`GuzzleHttp\Promise\Coroutine::of()`.
## Installation
```shell
composer require guzzlehttp/promises
```
## Version Guidance
| Version | Status | PHP Version |
|---------|---------------------|--------------|
| 1.x | Security fixes only | >=5.5,<8.3 |
| 2.x | Latest | >=7.2.5,<8.6 |
## Quick Start
A *promise* represents the eventual result of an asynchronous operation. The
primary way of interacting with a promise is through its `then` method, which
registers callbacks to receive either a promise's eventual value or the reason
why the promise cannot be fulfilled.
### Callbacks
Callbacks are registered with the `then` method by providing an optional
`$onFulfilled` followed by an optional `$onRejected` function.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise->then(
// $onFulfilled
function ($value) {
echo 'The promise was fulfilled.';
},
// $onRejected
function ($reason) {
echo 'The promise was rejected.';
}
);
```
*Resolving* a promise means that you either fulfill a promise with a *value* or
reject a promise with a *reason*. Resolving a promise triggers callbacks
registered with the promise's `then` method. These callbacks are triggered
only once and in the order in which they were added.
### Resolving a Promise
Promises are fulfilled using the `resolve($value)` method. Resolving a promise
with any value other than a `GuzzleHttp\Promise\RejectedPromise` will trigger
all of the onFulfilled callbacks (resolving a promise with a rejected promise
will reject the promise and trigger the `$onRejected` callbacks).
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise
->then(function ($value) {
// Return a value and don't break the chain
return "Hello, " . $value;
})
// This then is executed after the first then and receives the value
// returned from the first then.
->then(function ($value) {
echo $value;
});
// Resolving the promise triggers the $onFulfilled callbacks and outputs
// "Hello, reader."
$promise->resolve('reader.');
```
### Promise Forwarding
Promises can be chained one after the other. Each then in the chain is a new
promise. The return value of a promise is what's forwarded to the next
promise in the chain. Returning a promise in a `then` callback will cause the
subsequent promises in the chain to only be fulfilled when the returned promise
has been fulfilled. The next promise in the chain will be invoked with the
resolved value of the promise.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$nextPromise = new Promise();
$promise
->then(function ($value) use ($nextPromise) {
echo $value;
return $nextPromise;
})
->then(function ($value) {
echo $value;
});
// Triggers the first callback and outputs "A"
$promise->resolve('A');
// Triggers the second callback and outputs "B"
$nextPromise->resolve('B');
```
### Promise Rejection
When a promise is rejected, the `$onRejected` callbacks are invoked with the
rejection reason.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise->then(null, function ($reason) {
echo $reason;
});
$promise->reject('Error!');
// Outputs "Error!"
```
### Rejection Forwarding
If an exception is thrown in an `$onRejected` callback, subsequent
`$onRejected` callbacks are invoked with the thrown exception as the reason.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise->then(null, function ($reason) {
throw new Exception($reason);
})->then(null, function ($reason) {
assert($reason->getMessage() === 'Error!');
});
$promise->reject('Error!');
```
You can also forward a rejection down the promise chain by returning a
`GuzzleHttp\Promise\RejectedPromise` in either an `$onFulfilled` or
`$onRejected` callback.
```php
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\RejectedPromise;
$promise = new Promise();
$promise->then(null, function ($reason) {
return new RejectedPromise($reason);
})->then(null, function ($reason) {
assert($reason === 'Error!');
});
$promise->reject('Error!');
```
If an exception is not thrown in a `$onRejected` callback and the callback
does not return a rejected promise, downstream `$onFulfilled` callbacks are
invoked using the value returned from the `$onRejected` callback.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise
->then(null, function ($reason) {
return "It's ok";
})
->then(function ($value) {
assert($value === "It's ok");
});
$promise->reject('Error!');
```
## Synchronous Wait
You can synchronously force promises to complete using a promise's `wait`
method. When creating a promise, you can provide a wait function that is used
to synchronously force a promise to complete. When a wait function is invoked
it is expected to deliver a value to the promise or reject the promise. If the
wait function does not deliver a value, then an exception is thrown. The wait
function provided to a promise constructor is invoked when the `wait` function
of the promise is called.
```php
$promise = new Promise(function () use (&$promise) {
$promise->resolve('foo');
});
// Calling wait will return the value of the promise.
echo $promise->wait(); // outputs "foo"
```
If a throwable is encountered while invoking the wait function of a promise,
the promise is rejected with the throwable and the throwable is thrown.
```php
$promise = new Promise(function () use (&$promise) {
throw new Exception('foo');
});
$promise->wait(); // throws the exception.
```
Calling `wait` on a promise that has been fulfilled will not trigger the wait
function. It will simply return the previously resolved value.
```php
$promise = new Promise(function () { die('this is not called!'); });
$promise->resolve('foo');
echo $promise->wait(); // outputs "foo"
```
Calling `wait` on a promise that has been rejected will throw. If the rejection
reason is an instance of `\Throwable` the reason is thrown.
Otherwise, a `GuzzleHttp\Promise\RejectionException` is thrown and the reason
can be obtained by calling the `getReason` method of the exception.
```php
$promise = new Promise();
$promise->reject('foo');
$promise->wait();
```
> PHP Fatal error: Uncaught exception 'GuzzleHttp\Promise\RejectionException' with message 'The promise was rejected with value: foo'
### Unwrapping a Promise
When synchronously waiting on a promise, you are joining the state of the
promise into the current state of execution (i.e., return the value of the
promise if it was fulfilled or throw an exception if it was rejected). This is
called "unwrapping" the promise. Waiting on a promise will by default unwrap
the promise state.
You can force a promise to resolve and *not* unwrap the state of the promise
by passing `false` to the first argument of the `wait` function:
```php
$promise = new Promise();
$promise->reject('foo');
// This will not throw an exception. It simply ensures the promise has
// been resolved.
$promise->wait(false);
```
When unwrapping a promise, the resolved value of the promise will be waited
upon until the unwrapped value is not a promise. This means that if you resolve
promise A with a promise B and unwrap promise A, the value returned by the
wait function will be the value delivered to promise B.
**Note**: when you do not unwrap the promise, no value is returned.
## Cancellation
You can cancel a promise that has not yet been fulfilled using the `cancel()`
method of a promise. When creating a promise you can provide an optional
cancel function that when invoked cancels the action of computing a resolution
of the promise.
## API
### Promise
When creating a promise object, you can provide an optional `$waitFn` and
`$cancelFn`. `$waitFn` is a function that is invoked with no arguments and is
expected to resolve the promise. `$cancelFn` is a function with no arguments
that is expected to cancel the computation of a promise. It is invoked when the
`cancel()` method of a promise is called.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise(
function () use (&$promise) {
$promise->resolve('waited');
},
function () {
// do something that will cancel the promise computation (e.g., close
// a socket, cancel a database query, etc...)
}
);
assert('waited' === $promise->wait());
```
A promise has the following methods:
- `then(?callable $onFulfilled = null, ?callable $onRejected = null) : PromiseInterface`
Appends fulfillment and rejection handlers to the promise, and returns a new
promise resolving to the return value of the called handler. If a handler is
omitted, the original fulfillment value or rejection reason is forwarded.
- `otherwise(callable $onRejected) : PromiseInterface`
Appends a rejection handler callback to the promise, and returns a new promise resolving to the return value of the callback if it is called, or to its original fulfillment value if the promise is instead fulfilled.
- `wait($unwrap = true) : mixed`
Synchronously waits on the promise to complete.
`$unwrap` controls whether or not the value of the promise is returned for a
fulfilled promise or if an exception is thrown if the promise is rejected.
This is set to `true` by default.
- `cancel()`
Attempts to cancel the promise if possible. The promise being cancelled and
the parent most ancestor that has not yet been resolved will also be
cancelled. Any promises waiting on the cancelled promise to resolve will also
be cancelled.
- `getState() : string`
Returns the state of the promise. One of `pending`, `fulfilled`, or
`rejected`.
- `resolve($value)`
Fulfills the promise with the given `$value`.
- `reject($reason)`
Rejects the promise with the given `$reason`.
### FulfilledPromise
A fulfilled promise can be created to represent a promise that has been
fulfilled.
```php
use GuzzleHttp\Promise\FulfilledPromise;
$promise = new FulfilledPromise('value');
// Fulfilled callbacks are immediately invoked.
$promise->then(function ($value) {
echo $value;
});
```
### RejectedPromise
A rejected promise can be created to represent a promise that has been
rejected.
```php
use GuzzleHttp\Promise\RejectedPromise;
$promise = new RejectedPromise('Error');
// Rejected callbacks are immediately invoked.
$promise->then(null, function ($reason) {
echo $reason;
});
```
## Promise Interoperability
This library works with foreign promises that have a `then` method. This means
you can use Guzzle promises with [React promises](https://github.com/reactphp/promise)
for example. When a foreign promise is returned inside of a then method
callback, promise resolution will occur recursively.
```php
// Create a React promise
$deferred = new React\Promise\Deferred();
$reactPromise = $deferred->promise();
// Create a Guzzle promise that is fulfilled with a React promise.
$guzzlePromise = new GuzzleHttp\Promise\Promise();
$guzzlePromise->then(function ($value) use ($reactPromise) {
// Do something something with the value...
// Return the React promise
return $reactPromise;
});
```
Please note that wait and cancel chaining is no longer possible when forwarding
a foreign promise. You will need to wrap a third-party promise with a Guzzle
promise in order to utilize wait and cancel functions with foreign promises.
### Event Loop Integration
In order to keep the stack size constant, Guzzle promises are resolved
asynchronously using a task queue. When waiting on promises synchronously, the
task queue will be automatically run to ensure that the blocking promise and
any forwarded promises are resolved. When using promises asynchronously in an
event loop, you will need to run the task queue on each tick of the loop. If
you do not run the task queue, then promises will not be resolved.
You can run the task queue using the `run()` method of the global task queue
instance.
```php
// Get the global task queue
$queue = GuzzleHttp\Promise\Utils::queue();
$queue->run();
```
For example, you could use Guzzle promises with React using a short periodic
timer. Avoid zero-interval timers because they may keep the loop busy even when
there is no promise work to run.
```php
$loop = React\EventLoop\Factory::create();
$loop->addPeriodicTimer(0.01, [$queue, 'run']);
```
## Implementation Notes
### Promise Resolution and Chaining is Handled Iteratively
By shuffling pending handlers from one owner to another, promises are
resolved iteratively, allowing for "infinite" then chaining.
```php
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Promise\Promise;
$parent = new Promise();
$p = $parent;
for ($i = 0; $i < 1000; $i++) {
$p = $p->then(function ($v) {
// The stack size remains constant (a good thing)
echo xdebug_get_stack_depth() . ', ';
return $v + 1;
});
}
$parent->resolve(0);
var_dump($p->wait()); // int(1000)
```
When a promise is fulfilled or rejected with a non-promise value, the promise
then takes ownership of the handlers of each child promise and delivers values
down the chain without using recursion.
When a promise is resolved with another promise, the original promise transfers
all of its pending handlers to the new promise. When the new promise is
eventually resolved, all of the pending handlers are delivered the forwarded
value.
### A Promise is the Deferred
Some promise libraries implement promises using a deferred object to represent
a computation and a promise object to represent the delivery of the result of
the computation. This is a nice separation of computation and delivery because
consumers of the promise cannot modify the value that will be eventually
delivered.
One side effect of being able to implement promise resolution and chaining
iteratively is that you need to be able for one promise to reach into the state
of another promise to shuffle around ownership of handlers. In order to achieve
this without making the handlers of a promise publicly mutable, a promise is
also the deferred value, allowing promises of the same parent class to reach
into and modify the private properties of promises of the same type. While this
does allow consumers of the value to modify the resolution or rejection of the
deferred, it is a small price to pay for keeping the stack size constant.
```php
$promise = new Promise();
$promise->then(function ($value) { echo $value; });
// The promise is the deferred value, so you can deliver a value to it.
$promise->resolve('foo');
// prints "foo"
```
## Upgrading
See [UPGRADING.md](UPGRADING.md) for package upgrade notes.
## Security
If you discover a security vulnerability within this package, please send an email to security@tidelift.com. All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced. Please see [Security Policy](https://github.com/guzzle/promises/security/policy) for more information.
## License
Guzzle is made available under the MIT License (MIT). Please see [License File](LICENSE) for more information.
## For Enterprise
Available as part of the Tidelift Subscription
The maintainers of Guzzle and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-promises?utm_source=packagist-guzzlehttp-promises&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)

View file

@ -1,83 +0,0 @@
Guzzle Promises Upgrade Guide
=============================
1.x to 2.0
----------
Guzzle Promises 2.0 is a major release that removes deprecated APIs, raises the
minimum PHP version, and adds PHP 7 parameter and return types. Applications that
only use the object-oriented API should usually need small changes. Applications
that call helper functions, implement package interfaces, extend package classes,
or pass invalid argument types need closer review.
#### PHP Version and Dependencies
Guzzle Promises 2.0 requires PHP `^7.2.5 || ^8.0`. Guzzle Promises 1.x
supported PHP `>=5.5`.
#### PHP 7 Type Hints and Return Types
Type hints and return types were added wherever possible. Please make sure:
- You pass values of the documented type when calling methods and functions.
- Classes that implement `PromiseInterface`, `PromisorInterface`, or
`TaskQueueInterface` update method signatures to remain compatible.
- Classes that extend Guzzle Promises classes update any overridden method
signatures to remain compatible.
- Code that expected package-specific exceptions for invalid argument types may
now receive PHP `TypeError` exceptions instead.
#### Soft-Final Classes
All previously non-final non-exception classes are now final or annotated with
`@final`. If your code extends one of these classes, replace inheritance with
composition or implement the relevant interface directly.
#### Removed Function API
The static API was introduced in 1.4.0 to mitigate problems with functions
conflicting between global and local copies of the package. The function API was
removed in 2.0.0, along with the Composer `files` autoload entry that loaded
`src/functions_include.php`.
Replace namespaced function calls with the corresponding static methods in the
`GuzzleHttp\Promise` namespace:
```php
// Before:
use function GuzzleHttp\Promise\promise_for;
$promise = promise_for('value');
// After:
use GuzzleHttp\Promise\Create;
$promise = Create::promiseFor('value');
```
| Original Function | Replacement Method |
|-------------------|--------------------|
| `queue` | `Utils::queue` |
| `task` | `Utils::task` |
| `promise_for` | `Create::promiseFor` |
| `rejection_for` | `Create::rejectionFor` |
| `exception_for` | `Create::exceptionFor` |
| `iter_for` | `Create::iterFor` |
| `inspect` | `Utils::inspect` |
| `inspect_all` | `Utils::inspectAll` |
| `unwrap` | `Utils::unwrap` |
| `all` | `Utils::all` |
| `some` | `Utils::some` |
| `any` | `Utils::any` |
| `settle` | `Utils::settle` |
| `each` | `Each::of` |
| `each_limit` | `Each::ofLimit` |
| `each_limit_all` | `Each::ofLimitAll` |
| `!is_fulfilled` | `Is::pending` |
| `is_fulfilled` | `Is::fulfilled` |
| `is_rejected` | `Is::rejected` |
| `is_settled` | `Is::settled` |
| `coroutine` | `Coroutine::of` |
For the full 2.0 diff, see
https://github.com/guzzle/promises/compare/1.5.3...2.0.0.

View file

@ -168,6 +168,12 @@ final class Utils
if (true === $recursive) { if (true === $recursive) {
$promise = $promise->then(function ($results) use ($recursive, &$promises) { $promise = $promise->then(function ($results) use ($recursive, &$promises) {
// A consumed generator cannot be traversed again, so a
// recursive pass has nothing further to observe.
if ($promises instanceof \Generator) {
return $results;
}
foreach ($promises as $promise) { foreach ($promises as $promise) {
if (Is::pending($promise)) { if (Is::pending($promise)) {
return self::all($promises, $recursive); return self::all($promises, $recursive);

View file

@ -1,578 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.11.0 - 2026-06-02
### Changed
- Changed `Utils::modifyRequest()` to reject conflicting URI and `Host` header changes in the same call
- Changed `Header::parse()` to split semicolon-separated parameters without repeated regular expression lookaheads
- Changed `UriComparator::isCrossOrigin()` so only HTTP and HTTPS missing ports receive implicit default ports
### Deprecated
- Deprecated invalid PSR-7 arguments that guzzlehttp/psr7 3.0 will require native types for
- Deprecated non-string header values that guzzlehttp/psr7 3.0 will reject
- Deprecated empty header value arrays that guzzlehttp/psr7 3.0 will reject
- Deprecated URI schemes that do not match guzzlehttp/psr7 3.0 syntax requirements
- Deprecated multipart boundary and custom part header metadata that guzzlehttp/psr7 3.0 will reject
- Deprecated reliance on automatic uppercasing of request methods; guzzlehttp/psr7 3.0 preserves method casing
- Deprecated invalid `Utils::modifyRequest()` change values that guzzlehttp/psr7 3.0 will reject
### Fixed
- Fixed `Utils::copyToStream()` to retry short destination writes instead of dropping the unwritten remainder
- Fixed `Header::parse()` splitting of semicolon-separated parameters with escaped quotes
## 2.10.4 - 2026-05-29
### Fixed
- Apply `UriNormalizer` percent-encoding normalizations to URI fragments
- Make `LimitStream::getSize()` return `0` for slices past the underlying stream end
- Make `AppendStream::read()` return an empty string when no streams are attached
- Make `CachingStream::read()` throw on an incomplete cache-target write instead of silently corrupting replays
- Prevent `CachingStream::seek()` from looping indefinitely when the remote stream makes no progress
## 2.10.3 - 2026-05-27
### Fixed
- Fixed URI parsing for IPv6 literals containing embedded IPv4 addresses
- Fixed malformed UTF-8 URI strings being parsed as empty URIs
## 2.10.2 - 2026-05-25
### Security
- Reject control and whitespace characters in URI host components (GHSA-hq7v-mx3g-29hw)
- Reject malformed Host values when constructing request URIs (GHSA-34xg-wgjx-8xph)
### Fixed
- Make `ServerRequest::fromGlobals()` robust against unexpected HTTP header value types in `$_SERVER`
## 2.10.1 - 2026-05-20
### Fixed
- Fix `Utils::modifyRequest()` with numeric header names
## 2.10.0 - 2026-05-19
### Changed
- Harden `ServerRequest::fromGlobals()` against malformed `$_SERVER` values
- Prevent custom stream metadata from affecting internal size handling
- Throw when `StreamWrapper::getResource()` cannot create a resource
- Preserve custom request implementations in `Utils::modifyRequest()`
- Preserve custom URI implementations in `UriResolver::resolve()`
- Make `Uri::__toString()` side-effect-free
## 2.9.1 - 2026-05-19
### Fixed
- Fix parsing of relative path references containing a colon in a non-initial path segment
- Fix `CachingStream::detach()` returning an incomplete resource before the decorated stream has been fully read
- Fix `Message::bodySummary()` returning `null` when truncating printable UTF-8 bodies inside a multibyte character
## 2.9.0 - 2026-03-10
### Added
- Added nested array expansion support to `MultipartStream`
- Added `@return static` to `MessageTrait` methods
### Changed
- Updated MIME type mappings
## 2.8.1 - 2026-03-10
### Fixed
- Encode `+` signs in `Uri::withQueryValue()` and `Uri::withQueryValues()` to prevent them being interpreted as spaces
## 2.8.0 - 2025-08-23
### Added
- Allow empty lists as header values
### Changed
- PHP 8.5 support
## 2.7.1 - 2025-03-27
### Fixed
- Fixed uppercase IPv6 addresses in URI
### Changed
- Improve uploaded file error message
## 2.7.0 - 2024-07-18
### Added
- Add `Utils::redactUserInfo()` method
- Add ability to encode bools as ints in `Query::build`
## 2.6.3 - 2024-07-18
### Fixed
- Make `StreamWrapper::stream_stat()` return `false` if inner stream's size is `null`
### Changed
- PHP 8.4 support
## 2.6.2 - 2023-12-03
### Fixed
- Fixed another issue with the fact that PHP transforms numeric strings in array keys to ints
### Changed
- Updated links in docs to their canonical versions
- Replaced `call_user_func*` with native calls
## 2.6.1 - 2023-08-27
### Fixed
- Properly handle the fact that PHP transforms numeric strings in array keys to ints
## 2.6.0 - 2023-08-03
### Changed
- Updated the mime type map to add some new entries, fix a couple of invalid entries, and remove an invalid entry
- Fallback to `application/octet-stream` if we are unable to guess the content type for a multipart file upload
## 2.5.1 - 2023-08-03
### Fixed
- Corrected mime type for `.acc` files to `audio/aac`
### Changed
- PHP 8.3 support
## 2.5.0 - 2023-04-17
### Changed
- Adjusted `psr/http-message` version constraint to `^1.1 || ^2.0`
## 2.4.5 - 2023-04-17
### Fixed
- Prevent possible warnings on unset variables in `ServerRequest::normalizeNestedFileSpec`
- Fixed `Message::bodySummary` when `preg_match` fails
- Fixed header validation issue
## 2.4.4 - 2023-03-09
### Changed
- Removed the need for `AllowDynamicProperties` in `LazyOpenStream`
## 2.4.3 - 2022-10-26
### Changed
- Replaced `sha1(uniqid())` by `bin2hex(random_bytes(20))`
## 2.4.2 - 2022-10-25
### Fixed
- Fixed erroneous behaviour when combining host and relative path
## 2.4.1 - 2022-08-28
### Fixed
- Rewind body before reading in `Message::bodySummary`
## 2.4.0 - 2022-06-20
### Added
- Added provisional PHP 8.2 support
- Added `UriComparator::isCrossOrigin` method
## 2.3.0 - 2022-06-09
### Fixed
- Added `Header::splitList` method
- Added `Utils::tryGetContents` method
- Improved `Stream::getContents` method
- Updated mimetype mappings
## 2.2.2 - 2022-06-08
### Fixed
- Fix `Message::parseRequestUri` for numeric headers
- Re-wrap exceptions thrown in `fread` into runtime exceptions
- Throw an exception when multipart options is misformatted
## 2.2.1 - 2022-03-20
### Fixed
- Correct header value validation
## 2.2.0 - 2022-03-20
### Added
- A more compressive list of mime types
- Add JsonSerializable to Uri
- Missing return types
### Fixed
- Bug MultipartStream no `uri` metadata
- Bug MultipartStream with filename for `data://` streams
- Fixed new line handling in MultipartStream
- Reduced RAM usage when copying streams
- Updated parsing in `Header::normalize()`
## 2.1.1 - 2022-03-20
### Fixed
- Validate header values properly
## 2.1.0 - 2021-10-06
### Changed
- Attempting to create a `Uri` object from a malformed URI will no longer throw a generic
`InvalidArgumentException`, but rather a `MalformedUriException`, which inherits from the former
for backwards compatibility. Callers relying on the exception being thrown to detect invalid
URIs should catch the new exception.
### Fixed
- Return `null` in caching stream size if remote size is `null`
## 2.0.0 - 2021-06-30
Identical to the RC release.
## 2.0.0@RC-1 - 2021-04-29
### Fixed
- Handle possibly unset `url` in `stream_get_meta_data`
## 2.0.0@beta-1 - 2021-03-21
### Added
- PSR-17 factories
- Made classes final
- PHP7 type hints
### Changed
- When building a query string, booleans are represented as 1 and 0.
### Removed
- PHP < 7.2 support
- All functions in the `GuzzleHttp\Psr7` namespace
## 1.8.1 - 2021-03-21
### Fixed
- Issue parsing IPv6 URLs
- Issue modifying ServerRequest lost all its attributes
## 1.8.0 - 2021-03-21
### Added
- Locale independent URL parsing
- Most classes got a `@final` annotation to prepare for 2.0
### Fixed
- Issue when creating stream from `php://input` and curl-ext is not installed
- Broken `Utils::tryFopen()` on PHP 8
## 1.7.0 - 2020-09-30
### Added
- Replaced functions by static methods
### Fixed
- Converting a non-seekable stream to a string
- Handle multiple Set-Cookie correctly
- Ignore array keys in header values when merging
- Allow multibyte characters to be parsed in `Message:bodySummary()`
### Changed
- Restored partial HHVM 3 support
## [1.6.1] - 2019-07-02
### Fixed
- Accept null and bool header values again
## [1.6.0] - 2019-06-30
### Added
- Allowed version `^3.0` of `ralouphie/getallheaders` dependency (#244)
- Added MIME type for WEBP image format (#246)
- Added more validation of values according to PSR-7 and RFC standards, e.g. status code range (#250, #272)
### Changed
- Tests don't pass with HHVM 4.0, so HHVM support got dropped. Other libraries like composer have done the same. (#262)
- Accept port number 0 to be valid (#270)
### Fixed
- Fixed subsequent reads from `php://input` in ServerRequest (#247)
- Fixed readable/writable detection for certain stream modes (#248)
- Fixed encoding of special characters in the `userInfo` component of an URI (#253)
## [1.5.2] - 2018-12-04
### Fixed
- Check body size when getting the message summary
## [1.5.1] - 2018-12-04
### Fixed
- Get the summary of a body only if it is readable
## [1.5.0] - 2018-12-03
### Added
- Response first-line to response string exception (fixes #145)
- A test for #129 behavior
- `get_message_body_summary` function in order to get the message summary
- `3gp` and `mkv` mime types
### Changed
- Clarify exception message when stream is detached
### Deprecated
- Deprecated parsing folded header lines as per RFC 7230
### Fixed
- Fix `AppendStream::detach` to not close streams
- `InflateStream` preserves `isSeekable` attribute of the underlying stream
- `ServerRequest::getUriFromGlobals` to support URLs in query parameters
Several other fixes and improvements.
## [1.4.2] - 2017-03-20
### Fixed
- Reverted BC break to `Uri::resolve` and `Uri::removeDotSegments` by removing
calls to `trigger_error` when deprecated methods are invoked.
## [1.4.1] - 2017-02-27
### Added
- Rriggering of silenced deprecation warnings.
### Fixed
- Reverted BC break by reintroducing behavior to automagically fix a URI with a
relative path and an authority by adding a leading slash to the path. It's only
deprecated now.
## [1.4.0] - 2017-02-21
### Added
- Added common URI utility methods based on RFC 3986 (see documentation in the readme):
- `Uri::isDefaultPort`
- `Uri::isAbsolute`
- `Uri::isNetworkPathReference`
- `Uri::isAbsolutePathReference`
- `Uri::isRelativePathReference`
- `Uri::isSameDocumentReference`
- `Uri::composeComponents`
- `UriNormalizer::normalize`
- `UriNormalizer::isEquivalent`
- `UriResolver::relativize`
### Changed
- Ensure `ServerRequest::getUriFromGlobals` returns a URI in absolute form.
- Allow `parse_response` to parse a response without delimiting space and reason.
- Ensure each URI modification results in a valid URI according to PSR-7 discussions.
Invalid modifications will throw an exception instead of returning a wrong URI or
doing some magic.
- `(new Uri)->withPath('foo')->withHost('example.com')` will throw an exception
because the path of a URI with an authority must start with a slash "/" or be empty
- `(new Uri())->withScheme('http')` will return `'http://localhost'`
### Deprecated
- `Uri::resolve` in favor of `UriResolver::resolve`
- `Uri::removeDotSegments` in favor of `UriResolver::removeDotSegments`
### Fixed
- `Stream::read` when length parameter <= 0.
- `copy_to_stream` reads bytes in chunks instead of `maxLen` into memory.
- `ServerRequest::getUriFromGlobals` when `Host` header contains port.
- Compatibility of URIs with `file` scheme and empty host.
## [1.3.1] - 2016-06-25
### Fixed
- `Uri::__toString` for network path references, e.g. `//example.org`.
- Missing lowercase normalization for host.
- Handling of URI components in case they are `'0'` in a lot of places,
e.g. as a user info password.
- `Uri::withAddedHeader` to correctly merge headers with different case.
- Trimming of header values in `Uri::withAddedHeader`. Header values may
be surrounded by whitespace which should be ignored according to RFC 7230
Section 3.2.4. This does not apply to header names.
- `Uri::withAddedHeader` with an array of header values.
- `Uri::resolve` when base path has no slash and handling of fragment.
- Handling of encoding in `Uri::with(out)QueryValue` so one can pass the
key/value both in encoded as well as decoded form to those methods. This is
consistent with withPath, withQuery etc.
- `ServerRequest::withoutAttribute` when attribute value is null.
## [1.3.0] - 2016-04-13
### Added
- Remaining interfaces needed for full PSR7 compatibility
(ServerRequestInterface, UploadedFileInterface, etc.).
- Support for stream_for from scalars.
### Changed
- Can now extend Uri.
### Fixed
- A bug in validating request methods by making it more permissive.
## [1.2.3] - 2016-02-18
### Fixed
- Support in `GuzzleHttp\Psr7\CachingStream` for seeking forward on remote
streams, which can sometimes return fewer bytes than requested with `fread`.
- Handling of gzipped responses with FNAME headers.
## [1.2.2] - 2016-01-22
### Added
- Support for URIs without any authority.
- Support for HTTP 451 'Unavailable For Legal Reasons.'
- Support for using '0' as a filename.
- Support for including non-standard ports in Host headers.
## [1.2.1] - 2015-11-02
### Changes
- Now supporting negative offsets when seeking to SEEK_END.
## [1.2.0] - 2015-08-15
### Changed
- Body as `"0"` is now properly added to a response.
- Now allowing forward seeking in CachingStream.
- Now properly parsing HTTP requests that contain proxy targets in
`parse_request`.
- functions.php is now conditionally required.
- user-info is no longer dropped when resolving URIs.
## [1.1.0] - 2015-06-24
### Changed
- URIs can now be relative.
- `multipart/form-data` headers are now overridden case-insensitively.
- URI paths no longer encode the following characters because they are allowed
in URIs: "(", ")", "*", "!", "'"
- A port is no longer added to a URI when the scheme is missing and no port is
present.
## 1.0.0 - 2015-05-19
Initial release.
Currently unsupported:
- `Psr\Http\Message\ServerRequestInterface`
- `Psr\Http\Message\UploadedFileInterface`
[1.6.0]: https://github.com/guzzle/psr7/compare/1.5.2...1.6.0
[1.5.2]: https://github.com/guzzle/psr7/compare/1.5.1...1.5.2
[1.5.1]: https://github.com/guzzle/psr7/compare/1.5.0...1.5.1
[1.5.0]: https://github.com/guzzle/psr7/compare/1.4.2...1.5.0
[1.4.2]: https://github.com/guzzle/psr7/compare/1.4.1...1.4.2
[1.4.1]: https://github.com/guzzle/psr7/compare/1.4.0...1.4.1
[1.4.0]: https://github.com/guzzle/psr7/compare/1.3.1...1.4.0
[1.3.1]: https://github.com/guzzle/psr7/compare/1.3.0...1.3.1
[1.3.0]: https://github.com/guzzle/psr7/compare/1.2.3...1.3.0
[1.2.3]: https://github.com/guzzle/psr7/compare/1.2.2...1.2.3
[1.2.2]: https://github.com/guzzle/psr7/compare/1.2.1...1.2.2
[1.2.1]: https://github.com/guzzle/psr7/compare/1.2.0...1.2.1
[1.2.0]: https://github.com/guzzle/psr7/compare/1.1.0...1.2.0
[1.1.0]: https://github.com/guzzle/psr7/compare/1.0.0...1.1.0

View file

@ -1,880 +0,0 @@
# PSR-7 Message Implementation
This repository contains a full [PSR-7](https://www.php-fig.org/psr/psr-7/)
message implementation, several stream decorators, and some helpful
functionality like query string parsing.
![CI](https://github.com/guzzle/psr7/workflows/CI/badge.svg)
![Static analysis](https://github.com/guzzle/psr7/workflows/Static%20analysis/badge.svg)
## Features
This package comes with a number of stream implementations and stream
decorators.
## Installation
```shell
composer require guzzlehttp/psr7
```
## Version Guidance
| Version | Status | PHP Version |
|---------|---------------------|--------------|
| 1.x | EOL (2024-06-30) | >=5.4,<8.2 |
| 2.x | Latest | >=7.2.5,<8.6 |
See [UPGRADING.md](UPGRADING.md) for notes on upgrading from 1.x to 2.0.
## AppendStream
`GuzzleHttp\Psr7\AppendStream`
Reads from multiple streams, one after the other.
```php
use GuzzleHttp\Psr7;
$a = Psr7\Utils::streamFor('abc, ');
$b = Psr7\Utils::streamFor('123.');
$composed = new Psr7\AppendStream([$a, $b]);
$composed->addStream(Psr7\Utils::streamFor(' Above all listen to me'));
echo $composed; // abc, 123. Above all listen to me.
```
## BufferStream
`GuzzleHttp\Psr7\BufferStream`
Provides a buffer stream that can be written to fill a buffer, and read
from to remove bytes from the buffer.
This stream returns a "hwm" metadata value that tells upstream consumers
what the configured high water mark of the stream is, or the maximum
preferred size of the buffer.
```php
use GuzzleHttp\Psr7;
// When more than 1024 bytes are in the buffer, it will begin returning
// 0 to writes. This is an indication that writers should slow down.
$buffer = new Psr7\BufferStream(1024);
```
## CachingStream
The CachingStream is used to allow seeking over previously read bytes on
non-seekable streams. This can be useful when transferring a non-seekable
entity body fails due to needing to rewind the stream (for example, resulting
from a redirect). Data that is read from the remote stream will be buffered in
a PHP temp stream so that previously read bytes are cached first in memory,
then on disk.
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor(fopen('http://www.google.com', 'r'));
$stream = new Psr7\CachingStream($original);
$stream->read(1024);
echo $stream->tell();
// 1024
$stream->seek(0);
echo $stream->tell();
// 0
```
## DroppingStream
`GuzzleHttp\Psr7\DroppingStream`
Stream decorator that begins dropping data once the size of the underlying
stream becomes too full.
```php
use GuzzleHttp\Psr7;
// Create an empty stream
$stream = Psr7\Utils::streamFor();
// Start dropping data when the stream has more than 10 bytes
$dropping = new Psr7\DroppingStream($stream, 10);
$dropping->write('01234567890123456789');
echo $stream; // 0123456789
```
## FnStream
`GuzzleHttp\Psr7\FnStream`
Compose stream implementations based on a hash of callables.
Allows for easy testing and extension of a provided stream without needing
to create a concrete class for a simple extension point.
```php
use GuzzleHttp\Psr7;
$stream = Psr7\Utils::streamFor('hi');
$fnStream = Psr7\FnStream::decorate($stream, [
'rewind' => function () use ($stream) {
echo 'About to rewind - ';
$stream->rewind();
echo 'rewound!';
}
]);
$fnStream->rewind();
// Outputs: About to rewind - rewound!
```
## InflateStream
`GuzzleHttp\Psr7\InflateStream`
Uses PHP's zlib.inflate filter to inflate zlib (HTTP deflate, RFC1950) or gzipped (RFC1952) content.
This stream decorator converts the provided stream to a PHP stream resource,
then appends the zlib.inflate filter. The stream is then converted back
to a Guzzle stream resource to be used as a Guzzle stream.
## LazyOpenStream
`GuzzleHttp\Psr7\LazyOpenStream`
Lazily reads or writes to a file that is opened only after an IO operation
take place on the stream.
```php
use GuzzleHttp\Psr7;
$stream = new Psr7\LazyOpenStream('/path/to/file', 'r');
// The file has not yet been opened...
echo $stream->read(10);
// The file is opened and read from only when needed.
```
## LimitStream
`GuzzleHttp\Psr7\LimitStream`
LimitStream can be used to read a subset or slice of an existing stream object.
This can be useful for breaking a large file into smaller pieces to be sent in
chunks (e.g. Amazon S3's multipart upload API).
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor(fopen('/tmp/test.txt', 'r+'));
echo $original->getSize();
// >>> 1048576
// Limit the size of the body to 1024 bytes and start reading from byte 2048
$stream = new Psr7\LimitStream($original, 1024, 2048);
echo $stream->getSize();
// >>> 1024
echo $stream->tell();
// >>> 0
```
## MultipartStream
`GuzzleHttp\Psr7\MultipartStream`
Stream that when read returns bytes for a streaming multipart or
multipart/form-data stream.
Each multipart element must contain a `name` and `contents` key. `contents` may
be any non-array value accepted by `GuzzleHttp\Psr7\Utils::streamFor()`,
including closures and invokable objects. Array contents are recursively
expanded into nested form fields.
## NoSeekStream
`GuzzleHttp\Psr7\NoSeekStream`
NoSeekStream wraps a stream and does not allow seeking.
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor('foo');
$noSeek = new Psr7\NoSeekStream($original);
echo $noSeek->read(3);
// foo
var_export($noSeek->isSeekable());
// false
$noSeek->seek(0);
var_export($noSeek->read(3));
// NULL
```
## PumpStream
`GuzzleHttp\Psr7\PumpStream`
Provides a read only stream that pumps data from a PHP callable.
When invoking the provided callable, the PumpStream will pass the suggested
number of bytes to read to the callable. The callable can choose to ignore
this value and return fewer or more bytes than requested. Any extra data
returned by the provided callable is buffered internally until drained using
the read() function of the PumpStream. The provided callable MUST return
false or null when there is no more data to read.
Userland callables that declare no parameters are tolerated by PHP, but
length-aware callables remain the recommended formal shape.
## Implementing stream decorators
Creating a stream decorator is very easy thanks to the
`GuzzleHttp\Psr7\StreamDecoratorTrait`. This trait provides methods that
implement `Psr\Http\Message\StreamInterface` by proxying to an underlying
stream. Just `use` the `StreamDecoratorTrait` and implement your custom
methods.
For example, let's say we wanted to call a specific function each time the last
byte is read from a stream. This could be implemented by overriding the
`read()` method.
```php
use Psr\Http\Message\StreamInterface;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
class EofCallbackStream implements StreamInterface
{
use StreamDecoratorTrait;
private $callback;
private $stream;
public function __construct(StreamInterface $stream, callable $cb)
{
$this->stream = $stream;
$this->callback = $cb;
}
public function read($length)
{
$result = $this->stream->read($length);
// Invoke the callback when EOF is hit.
if ($this->eof()) {
($this->callback)();
}
return $result;
}
}
```
This decorator could be added to any existing stream and used like so:
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor('foo');
$eofStream = new EofCallbackStream($original, function () {
echo 'EOF!';
});
$eofStream->read(2);
$eofStream->read(1);
// echoes "EOF!"
$eofStream->seek(0);
$eofStream->read(3);
// echoes "EOF!"
```
## PHP StreamWrapper
You can use the `GuzzleHttp\Psr7\StreamWrapper` class if you need to use a
PSR-7 stream as a PHP stream resource.
Use the `GuzzleHttp\Psr7\StreamWrapper::getResource()` method to create a PHP
stream from a PSR-7 stream.
```php
use GuzzleHttp\Psr7\StreamWrapper;
$stream = GuzzleHttp\Psr7\Utils::streamFor('hello!');
$resource = StreamWrapper::getResource($stream);
echo fread($resource, 6); // outputs hello!
```
# Static API
There are various static methods available under the `GuzzleHttp\Psr7` namespace.
## `GuzzleHttp\Psr7\Message::toString`
`public static function toString(MessageInterface $message): string`
Returns the string representation of an HTTP message.
```php
$request = new GuzzleHttp\Psr7\Request('GET', 'http://example.com');
echo GuzzleHttp\Psr7\Message::toString($request);
```
## `GuzzleHttp\Psr7\Message::bodySummary`
`public static function bodySummary(MessageInterface $message, int $truncateAt = 120): string|null`
Get a short summary of the message body.
Will return `null` if the response is not printable.
## `GuzzleHttp\Psr7\Message::rewindBody`
`public static function rewindBody(MessageInterface $message): void`
Attempts to rewind a message body and throws an exception on failure.
The body of the message will only be rewound if a call to `tell()`
returns a value other than `0`.
## `GuzzleHttp\Psr7\Message::parseMessage`
`public static function parseMessage(string $message): array`
Parses an HTTP message into an associative array.
The array contains the "start-line" key containing the start line of
the message, "headers" key containing an associative array of header
array values, and a "body" key containing the body of the message.
## `GuzzleHttp\Psr7\Message::parseRequestUri`
`public static function parseRequestUri(string $path, array $headers): string`
Constructs a URI for an HTTP request message.
## `GuzzleHttp\Psr7\Message::parseRequest`
`public static function parseRequest(string $message): Request`
Parses a request message string into a request object.
## `GuzzleHttp\Psr7\Message::parseResponse`
`public static function parseResponse(string $message): Response`
Parses a response message string into a response object.
## `GuzzleHttp\Psr7\Header::parse`
`public static function parse(string|array $header): array`
Parse an array of header values containing ";" separated data into an
array of associative arrays representing the header key value pair data
of the header. When a parameter does not contain a value, but just
contains a key, this function will inject a key with a '' string value.
## `GuzzleHttp\Psr7\Header::splitList`
`public static function splitList(string|string[] $header): string[]`
Splits a HTTP header defined to contain a comma-separated list into
each individual value:
```
$knownEtags = Header::splitList($request->getHeader('if-none-match'));
```
Example headers include `accept`, `cache-control` and `if-none-match`.
## `GuzzleHttp\Psr7\Header::normalize` (deprecated)
`public static function normalize(string|array $header): array`
`Header::normalize()` is deprecated in favor of [`Header::splitList()`](README.md#guzzlehttppsr7headersplitlist)
which performs the same operation with a cleaned up API and improved
documentation.
Converts an array of header values that may contain comma separated
headers into an array of headers with no comma separated values.
## `GuzzleHttp\Psr7\Query::parse`
`public static function parse(string $str, int|bool $urlEncoding = true): array`
Parse a query string into an associative array.
If multiple values are found for the same key, the value of that key
value pair will become an array. This function does not parse nested
PHP style arrays into an associative array (e.g., `foo[a]=1&foo[b]=2`
will be parsed into `['foo[a]' => '1', 'foo[b]' => '2'])`.
## `GuzzleHttp\Psr7\Query::build`
`public static function build(array $params, int|false $encoding = PHP_QUERY_RFC3986, bool $treatBoolsAsInts = true): string`
Build a query string from an array of key value pairs.
This function can use the return value of `parse()` to build a query
string. This function does not modify the provided keys when an array is
encountered (like `http_build_query()` would).
## `GuzzleHttp\Psr7\Utils::caselessRemove`
`public static function caselessRemove(iterable<string> $keys, $keys, array $data): array`
Remove the items given by the keys, case insensitively from the data.
## `GuzzleHttp\Psr7\Utils::copyToStream`
`public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): void`
Copy the contents of a stream into another stream until the given number
of bytes have been read.
The copy stops if the destination `write()` returns 0, for example a
`BufferStream` at its high water mark or a full `DroppingStream`. For a
guaranteed full copy, use a normal writable stream such as a file or
`php://temp` stream.
## `GuzzleHttp\Psr7\Utils::copyToString`
`public static function copyToString(StreamInterface $stream, int $maxLen = -1): string`
Copy the contents of a stream into a string until the given number of
bytes have been read.
## `GuzzleHttp\Psr7\Utils::hash`
`public static function hash(StreamInterface $stream, string $algo, bool $rawOutput = false): string`
Calculate a hash of a stream.
This method reads the entire stream to calculate a rolling hash, based on
PHP's `hash_init` functions.
## `GuzzleHttp\Psr7\Utils::modifyRequest`
`public static function modifyRequest(RequestInterface $request, array $changes): RequestInterface`
Clone and modify a request with the given changes.
This method is useful for reducing the number of clones needed to mutate
a message.
- method: (string) Changes the HTTP method.
- set_headers: (array) Sets the given headers.
- remove_headers: (array) Remove the given headers.
- body: (mixed) Sets the given body. Present non-null values are converted with
`GuzzleHttp\Psr7\Utils::streamFor()`, including scalar values, resources,
streams, iterators, callable arrays, closures, invokable objects, and
objects with `__toString()`. String inputs remain literal bodies.
- uri: (UriInterface) Set the URI.
- query: (string) Set the query string value of the URI.
- version: (string) Set the protocol version.
## `GuzzleHttp\Psr7\Utils::readLine`
`public static function readLine(StreamInterface $stream, ?int $maxLength = null): string`
Read a line from the stream up to the maximum allowed buffer length.
## `GuzzleHttp\Psr7\Utils::redactUserInfo`
`public static function redactUserInfo(UriInterface $uri): UriInterface`
Redact the password in the user info part of a URI.
## `GuzzleHttp\Psr7\Utils::streamFor`
`public static function streamFor(resource|string|null|int|float|bool|StreamInterface|callable|\Iterator $resource = '', array $options = []): StreamInterface`
Create a new stream based on the input type.
Options is an associative array that can contain the following keys:
- metadata: Array of custom metadata.
- size: Size of the stream.
This method accepts the following `$resource` types:
- `Psr\Http\Message\StreamInterface`: Returns the value as-is.
- `string`: Creates a stream object that uses the given string as the contents.
- `resource`: Creates a stream object that wraps the given PHP stream resource.
- `Iterator`: If the provided value implements `Iterator`, then a read-only
stream object will be created that wraps the given iterable. Each time the
stream is read from, data from the iterator will fill a buffer and will be
continuously called until the buffer is equal to the requested read size.
Subsequent read calls will first read from the buffer and then call `next`
on the underlying iterator until it is exhausted.
- `object` with `__toString()`: If the object has the `__toString()` method,
the object will be cast to a string and then a stream will be returned that
uses the string value.
- `NULL`: When `null` is passed, an empty stream object is returned.
- `callable`: When a callable array, closure, or invokable object is passed and
no earlier resource or object rule applies, a read-only stream object will be
created that invokes the given callable. The callable is invoked with the
suggested number of bytes to read. The callable can return fewer or more bytes
than requested, but MUST return `false` or `null` when there is no more data
to return. Any additional bytes will be buffered and used in subsequent reads.
String inputs are always treated as string bodies, even when they name
callable functions.
```php
$stream = GuzzleHttp\Psr7\Utils::streamFor('foo');
$stream = GuzzleHttp\Psr7\Utils::streamFor(fopen('/path/to/file', 'r'));
$generator = function ($bytes) {
for ($i = 0; $i < $bytes; $i++) {
yield ' ';
}
}
$stream = GuzzleHttp\Psr7\Utils::streamFor($generator(100));
```
## `GuzzleHttp\Psr7\Utils::tryFopen`
`public static function tryFopen(string $filename, string $mode): resource`
Safely opens a PHP stream resource using a filename.
When fopen fails, PHP normally raises a warning. This function adds an
error handler that checks for errors and throws an exception instead.
## `GuzzleHttp\Psr7\Utils::tryGetContents`
`public static function tryGetContents(resource $stream): string`
Safely gets the contents of a given stream.
When stream_get_contents fails, PHP normally raises a warning. This
function adds an error handler that checks for errors and throws an
exception instead.
## `GuzzleHttp\Psr7\Utils::uriFor`
`public static function uriFor(string|UriInterface $uri): UriInterface`
Returns a UriInterface for the given value.
This function accepts a string or UriInterface and returns a
UriInterface for the given value. If the value is already a
UriInterface, it is returned as-is.
## `GuzzleHttp\Psr7\MimeType::fromFilename`
`public static function fromFilename(string $filename): string|null`
Determines the mimetype of a file by looking at its extension.
## `GuzzleHttp\Psr7\MimeType::fromExtension`
`public static function fromExtension(string $extension): string|null`
Maps a file extensions to a mimetype.
# Additional URI Methods
Aside from the standard `Psr\Http\Message\UriInterface` implementation in form of the `GuzzleHttp\Psr7\Uri` class,
this library also provides additional functionality when working with URIs as static methods.
## URI Types
An instance of `Psr\Http\Message\UriInterface` can either be an absolute URI or a relative reference.
An absolute URI has a scheme. A relative reference is used to express a URI relative to another URI,
the base URI. Relative references can be divided into several forms according to
[RFC 3986 Section 4.2](https://datatracker.ietf.org/doc/html/rfc3986#section-4.2):
- network-path references, e.g. `//example.com/path`
- absolute-path references, e.g. `/path`
- relative-path references, e.g. `subpath`
The following methods can be used to identify the type of the URI.
### `GuzzleHttp\Psr7\Uri::isAbsolute`
`public static function isAbsolute(UriInterface $uri): bool`
Whether the URI is absolute, i.e. it has a scheme.
### `GuzzleHttp\Psr7\Uri::isNetworkPathReference`
`public static function isNetworkPathReference(UriInterface $uri): bool`
Whether the URI is a network-path reference. A relative reference that begins with two slash characters is
termed an network-path reference.
### `GuzzleHttp\Psr7\Uri::isAbsolutePathReference`
`public static function isAbsolutePathReference(UriInterface $uri): bool`
Whether the URI is a absolute-path reference. A relative reference that begins with a single slash character is
termed an absolute-path reference.
### `GuzzleHttp\Psr7\Uri::isRelativePathReference`
`public static function isRelativePathReference(UriInterface $uri): bool`
Whether the URI is a relative-path reference. A relative reference that does not begin with a slash character is
termed a relative-path reference.
### `GuzzleHttp\Psr7\Uri::isSameDocumentReference`
`public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool`
Whether the URI is a same-document reference. A same-document reference refers to a URI that is, aside from its
fragment component, identical to the base URI. When no base URI is given, only an empty URI reference
(apart from its fragment) is considered a same-document reference.
## URI Components
Additional methods to work with URI components.
### `GuzzleHttp\Psr7\Uri::isDefaultPort`
`public static function isDefaultPort(UriInterface $uri): bool`
Whether the URI has the default port of the current scheme. `Psr\Http\Message\UriInterface::getPort` may return null
or the standard port. This method can be used independently of the implementation.
### `GuzzleHttp\Psr7\Uri::composeComponents`
`public static function composeComponents($scheme, $authority, $path, $query, $fragment): string`
Composes a URI reference string from its various components according to
[RFC 3986 Section 5.3](https://datatracker.ietf.org/doc/html/rfc3986#section-5.3). Usually this method does not need
to be called manually but instead is used indirectly via `Psr\Http\Message\UriInterface::__toString`.
### `GuzzleHttp\Psr7\Uri::fromParts`
`public static function fromParts(array $parts): UriInterface`
Creates a URI from a hash of [`parse_url`](https://www.php.net/manual/en/function.parse-url.php) components.
### `GuzzleHttp\Psr7\Uri::withQueryValue`
`public static function withQueryValue(UriInterface $uri, $key, $value): UriInterface`
Creates a new URI with a specific query string value. Any existing query string values that exactly match the
provided key are removed and replaced with the given key value pair. A value of null will set the query string
key without a value, e.g. "key" instead of "key=value".
### `GuzzleHttp\Psr7\Uri::withQueryValues`
`public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface`
Creates a new URI with multiple query string values. It has the same behavior as `withQueryValue()` but for an
associative array of key => value.
### `GuzzleHttp\Psr7\Uri::withoutQueryValue`
`public static function withoutQueryValue(UriInterface $uri, $key): UriInterface`
Creates a new URI with a specific query string value removed. Any existing query string values that exactly match the
provided key are removed.
## Cross-Origin Detection
`GuzzleHttp\Psr7\UriComparator` provides methods to determine if a modified URL should be considered cross-origin.
### `GuzzleHttp\Psr7\UriComparator::isCrossOrigin`
`public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool`
Determines if a modified URL should be considered cross-origin with respect to an original URL.
Two URLs are cross-origin when their scheme, host, or effective port differ. Host comparison is case-insensitive, and missing ports use the default port for `http` or `https`. Other schemes do not receive implicit default ports.
This helper only compares URI origins. It does not implement redirect handling or credential policy.
## Reference Resolution
`GuzzleHttp\Psr7\UriResolver` provides methods to resolve a URI reference in the context of a base URI according
to [RFC 3986 Section 5](https://datatracker.ietf.org/doc/html/rfc3986#section-5). This is for example also what web
browsers do when resolving a link in a website based on the current request URI.
### `GuzzleHttp\Psr7\UriResolver::resolve`
`public static function resolve(UriInterface $base, UriInterface $rel): UriInterface`
Converts the relative URI into a new URI that is resolved against the base URI.
### `GuzzleHttp\Psr7\UriResolver::removeDotSegments`
`public static function removeDotSegments(string $path): string`
Removes dot segments from a path and returns the new path according to
[RFC 3986 Section 5.2.4](https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4).
### `GuzzleHttp\Psr7\UriResolver::relativize`
`public static function relativize(UriInterface $base, UriInterface $target): UriInterface`
Returns the target URI as a relative reference from the base URI. This method is the counterpart to resolve():
```php
(string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target))
```
One use-case is to use the current request URI as base URI and then generate relative links in your documents
to reduce the document size or offer self-contained downloadable document archives.
```php
$base = new Uri('http://example.com/a/b/');
echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'.
echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'.
echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'.
echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'.
```
## Normalization and Comparison
`GuzzleHttp\Psr7\UriNormalizer` provides methods to normalize and compare URIs according to
[RFC 3986 Section 6](https://datatracker.ietf.org/doc/html/rfc3986#section-6).
### `GuzzleHttp\Psr7\UriNormalizer::normalize`
`public static function normalize(UriInterface $uri, $flags = self::PRESERVING_NORMALIZATIONS): UriInterface`
Returns a normalized URI. The scheme and host component are already normalized to lowercase per PSR-7 UriInterface.
This methods adds additional normalizations that can be configured with the `$flags` parameter which is a bitmask
of normalizations to apply. The following normalizations are available:
- `UriNormalizer::PRESERVING_NORMALIZATIONS`
Default normalizations which only include the ones that preserve semantics.
- `UriNormalizer::CAPITALIZE_PERCENT_ENCODING`
All letters within a percent-encoding triplet (e.g., "%3A") are case-insensitive, and should be capitalized.
Example: `http://example.org/a%c2%b1b``http://example.org/a%C2%B1b`
- `UriNormalizer::DECODE_UNRESERVED_CHARACTERS`
Decodes percent-encoded octets of unreserved characters. For consistency, percent-encoded octets in the ranges of
ALPHA (%41%5A and %61%7A), DIGIT (%30%39), hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E) should
not be created by URI producers and, when found in a URI, should be decoded to their corresponding unreserved
characters by URI normalizers.
Example: `http://example.org/%7Eusern%61me/``http://example.org/~username/`
- `UriNormalizer::CONVERT_EMPTY_PATH`
Converts the empty path to "/" for http and https URIs.
Example: `http://example.org``http://example.org/`
- `UriNormalizer::REMOVE_DEFAULT_HOST`
Removes the default host of the given URI scheme from the URI. Only the "file" scheme defines the default host
"localhost". All of `file:/myfile`, `file:///myfile`, and `file://localhost/myfile` are equivalent according to
RFC 3986.
Example: `file://localhost/myfile``file:///myfile`
- `UriNormalizer::REMOVE_DEFAULT_PORT`
Removes the default port of the given URI scheme from the URI.
Example: `http://example.org:80/``http://example.org/`
- `UriNormalizer::REMOVE_DOT_SEGMENTS`
Removes unnecessary dot-segments. Dot-segments in relative-path references are not removed as it would
change the semantics of the URI reference.
Example: `http://example.org/../a/b/../c/./d.html``http://example.org/a/c/d.html`
- `UriNormalizer::REMOVE_DUPLICATE_SLASHES`
Paths which include two or more adjacent slashes are converted to one. Webservers usually ignore duplicate slashes
and treat those URIs equivalent. But in theory those URIs do not need to be equivalent. So this normalization
may change the semantics. Encoded slashes (%2F) are not removed.
Example: `http://example.org//foo///bar.html``http://example.org/foo/bar.html`
- `UriNormalizer::SORT_QUERY_PARAMETERS`
Sort query parameters with their values in alphabetical order. However, the order of parameters in a URI may be
significant (this is not defined by the standard). So this normalization is not safe and may change the semantics
of the URI.
Example: `?lang=en&article=fred``?article=fred&lang=en`
### `GuzzleHttp\Psr7\UriNormalizer::isEquivalent`
`public static function isEquivalent(UriInterface $uri1, UriInterface $uri2, $normalizations = self::PRESERVING_NORMALIZATIONS): bool`
Whether two URIs can be considered equivalent. Both URIs are normalized automatically before comparison with the given
`$normalizations` bitmask. The method also accepts relative URI references and returns true when they are equivalent.
This of course assumes they will be resolved against the same base URI. If this is not the case, determination of
equivalence or difference of relative references does not mean anything.
## Security
If you discover a security vulnerability within this package, please send an email to security@tidelift.com. All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced. Please see [Security Policy](https://github.com/guzzle/psr7/security/policy) for more information.
## License
Guzzle is made available under the MIT License (MIT). Please see [License File](LICENSE) for more information.
## For Enterprise
Available as part of the Tidelift Subscription
The maintainers of Guzzle and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-psr7?utm_source=packagist-guzzlehttp-psr7&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)

View file

@ -1,198 +0,0 @@
Guzzle PSR-7 Upgrade Guide
==========================
1.x to 2.0
----------
Guzzle PSR-7 2.0 is a major release that removes deprecated APIs, raises the
minimum PHP version, and adds PHP 7 parameter and return types. Applications that
only depend on PSR-7 interfaces should usually need small changes. Applications
that call helper functions, extend package classes, or pass invalid argument
types need closer review.
#### PHP Version and Dependencies
Guzzle PSR-7 2.0 requires PHP `^7.2.5 || ^8.0`. Guzzle PSR-7 1.x supported PHP
`>=5.4.0`.
Composer dependency changes that can affect upgrades:
- `ralouphie/getallheaders` v2 support was dropped; 2.0 requires `^3.0`.
- `psr/http-factory:^1.0` is required because 2.0 ships PSR-17 factories through `GuzzleHttp\Psr7\HttpFactory`.
#### PHP 7 Type Hints and Return Types
Type hints and return types were added wherever possible. Please make sure:
- You pass values of the documented type when calling methods and functions.
- Classes that extend Guzzle PSR-7 classes update any overridden method signatures to remain compatible.
- Code that expected package-specific `InvalidArgumentException` exceptions for invalid argument types may now receive PHP `TypeError` exceptions instead.
Common examples include passing a real integer status code to `Response::__construct()` and passing a string method to `Request::__construct()`.
#### Removed Function API
The static API was introduced in 1.7.0 to mitigate problems with functions
conflicting between global and local copies of the package. The function API was
removed in 2.0.0, along with the Composer `files` autoload entry that loaded
`src/functions_include.php`.
Replace namespaced function calls with the corresponding static methods in the
`GuzzleHttp\Psr7` namespace:
```php
// Before:
use function GuzzleHttp\Psr7\stream_for;
$stream = stream_for('body');
// After:
use GuzzleHttp\Psr7\Utils;
$stream = Utils::streamFor('body');
```
| Original Function | Replacement Method |
|-------------------|--------------------|
| `str` | `Message::toString` |
| `uri_for` | `Utils::uriFor` |
| `stream_for` | `Utils::streamFor` |
| `parse_header` | `Header::parse` |
| `normalize_header` | `Header::normalize` |
| `modify_request` | `Utils::modifyRequest` |
| `rewind_body` | `Message::rewindBody` |
| `try_fopen` | `Utils::tryFopen` |
| `copy_to_string` | `Utils::copyToString` |
| `copy_to_stream` | `Utils::copyToStream` |
| `hash` | `Utils::hash` |
| `readline` | `Utils::readLine` |
| `parse_request` | `Message::parseRequest` |
| `parse_response` | `Message::parseResponse` |
| `parse_query` | `Query::parse` |
| `build_query` | `Query::build` |
| `mimetype_from_filename` | `MimeType::fromFilename` |
| `mimetype_from_extension` | `MimeType::fromExtension` |
| `_parse_message` | `Message::parseMessage` |
| `_parse_request_uri` | `Message::parseRequestUri` |
| `get_message_body_summary` | `Message::bodySummary` |
| `_caseless_remove` | `Utils::caselessRemove` |
`Header::normalize()` remains the direct 2.0 replacement for
`normalize_header()`. In newer 2.x versions, prefer `Header::splitList()` for
new code.
#### Deprecated URI Methods Removed
The deprecated `Uri::resolve()` and `Uri::removeDotSegments()` methods were
removed. Use `UriResolver` instead.
```php
// Before:
$resolved = Uri::resolve($base, '../path');
$path = Uri::removeDotSegments('/a/../b');
// After:
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\Psr7\Utils;
$resolved = UriResolver::resolve($base, Utils::uriFor('../path'));
$path = UriResolver::removeDotSegments('/a/../b');
```
#### Stricter URI Validation
Guzzle PSR-7 1.x automatically fixed a URI that combined an authority with a
relative path by prepending `/` to the path. That deprecated behavior was removed
in 2.0. Such URIs now throw `InvalidArgumentException`.
```php
// Before: automatically converted to //example.com/foo.
$uri = (new Uri())->withHost('example.com')->withPath('foo');
// After: make the absolute path explicit.
$uri = (new Uri())->withHost('example.com')->withPath('/foo');
```
#### Header Validation
Header names are validated more strictly according to RFC 7230 token syntax.
Names containing whitespace, `/`, `(`, `)`, `\\`, or other invalid characters are
rejected.
If you construct messages from untrusted or non-standard input, normalize or
reject invalid header names before constructing `Request`, `Response`, or
`ServerRequest` instances.
#### Query String Boolean Serialization
`Query::build()` now serializes booleans as `1` and `0`, matching
`http_build_query()` behavior.
```php
Query::build(['enabled' => true, 'disabled' => false]);
// enabled=1&disabled=0
```
In current 2.x versions, pass `false` as the third argument if you need textual
boolean values:
```php
Query::build(['enabled' => true, 'disabled' => false], PHP_QUERY_RFC3986, false);
// enabled=true&disabled=false
```
#### Final Stream and Decorator Classes
Several classes that were annotated with `@final` in 1.x are declared `final` in
2.0:
- `AppendStream`
- `BufferStream`
- `CachingStream`
- `DroppingStream`
- `FnStream`
- `InflateStream`
- `LazyOpenStream`
- `LimitStream`
- `MultipartStream`
- `NoSeekStream`
- `PumpStream`
- `StreamWrapper`
If your code extends one of these classes, replace inheritance with composition.
For custom streams, implement `Psr\Http\Message\StreamInterface` directly or use
`GuzzleHttp\Psr7\StreamDecoratorTrait` in your own class.
`Request`, `Response`, `ServerRequest`, `Stream`, `UploadedFile`, and `Uri` remain
extendable in 2.0, but overridden methods must have compatible signatures.
#### Public Constants and Internal Details
Some constants that were public in 1.x are implementation details in 2.0:
- `Stream::READABLE_MODES`
- `Stream::WRITABLE_MODES`
- `Uri::HTTP_DEFAULT_HOST`
If your code used these constants, define application-specific constants instead
of depending on package internals.
#### Stream Behavior Changes
`BufferStream::write()` returns `0` instead of `false` when the buffer exceeds
its high-water mark. This keeps the method compatible with the `int` return type
from `StreamInterface::write()`.
Several stream `__toString()` implementations now catch `Throwable`. On PHP 7.4
and newer, exceptions thrown during stringification are rethrown. Avoid relying
on `(string) $stream` to hide read failures; call `getContents()` or `read()` and
handle exceptions when failures are possible.
#### PSR-17 Factories
Guzzle PSR-7 2.0 adds `GuzzleHttp\Psr7\HttpFactory`, an implementation of the
PSR-17 factory interfaces from `psr/http-factory`. This is additive, but it is
the reason for the new required dependency.
For the full 2.0 diff, see
https://github.com/guzzle/psr7/compare/1.8.1...2.0.0.

View file

@ -95,6 +95,8 @@ final class Header
*/ */
public static function normalize($header): array public static function normalize($header): array
{ {
\trigger_deprecation('guzzlehttp/psr7', '2.3', 'Header::normalize() is deprecated and will be removed in guzzlehttp/psr7 3.0. Use Header::splitList() instead.');
$result = []; $result = [];
foreach ((array) $header as $value) { foreach ((array) $header as $value) {
foreach (self::splitList($value) as $parsed) { foreach (self::splitList($value) as $parsed) {
@ -142,7 +144,7 @@ final class Header
} }
if (!$isQuoted && $value[$i] === ',') { if (!$isQuoted && $value[$i] === ',') {
$v = \trim($v); $v = \trim($v, " \n\r\t\0\x0B");
if ($v !== '') { if ($v !== '') {
$result[] = $v; $result[] = $v;
} }
@ -167,7 +169,7 @@ final class Header
$v .= $value[$i]; $v .= $value[$i];
} }
$v = \trim($v); $v = \trim($v, " \n\r\t\0\x0B");
if ($v !== '') { if ($v !== '') {
$result[] = $v; $result[] = $v;
} }

View file

@ -19,7 +19,7 @@ final class Message
{ {
if ($message instanceof RequestInterface) { if ($message instanceof RequestInterface) {
$msg = trim($message->getMethod().' ' $msg = trim($message->getMethod().' '
.$message->getRequestTarget()) .$message->getRequestTarget(), " \n\r\t\0\x0B")
.' HTTP/'.$message->getProtocolVersion(); .' HTTP/'.$message->getProtocolVersion();
if (!$message->hasHeader('host')) { if (!$message->hasHeader('host')) {
$msg .= "\r\nHost: ".$message->getUri()->getHost(); $msg .= "\r\nHost: ".$message->getUri()->getHost();
@ -33,7 +33,7 @@ final class Message
} }
foreach ($message->getHeaders() as $name => $values) { foreach ($message->getHeaders() as $name => $values) {
if (is_string($name) && strtolower($name) === 'set-cookie') { if (is_string($name) && Utils::asciiToLower($name) === 'set-cookie') {
foreach ($values as $value) { foreach ($values as $value) {
$msg .= "\r\n{$name}: ".$value; $msg .= "\r\n{$name}: ".$value;
} }
@ -181,7 +181,11 @@ final class Message
$messageParts = preg_split("/\r?\n\r?\n/", $message, 2); $messageParts = preg_split("/\r?\n\r?\n/", $message, 2);
if ($messageParts === false || count($messageParts) !== 2) { if ($messageParts === false) {
throw new \RuntimeException('Unable to split HTTP message: '.preg_last_error_msg());
}
if (count($messageParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing header delimiter'); throw new \InvalidArgumentException('Invalid message: Missing header delimiter');
} }
@ -189,24 +193,48 @@ final class Message
$rawHeaders .= "\r\n"; // Put back the delimiter we split previously $rawHeaders .= "\r\n"; // Put back the delimiter we split previously
$headerParts = preg_split("/\r?\n/", $rawHeaders, 2); $headerParts = preg_split("/\r?\n/", $rawHeaders, 2);
if ($headerParts === false || count($headerParts) !== 2) { if ($headerParts === false) {
throw new \RuntimeException('Unable to split HTTP message headers: '.preg_last_error_msg());
}
if (count($headerParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing status line'); throw new \InvalidArgumentException('Invalid message: Missing status line');
} }
[$startLine, $rawHeaders] = $headerParts; [$startLine, $rawHeaders] = $headerParts;
if (preg_match("/(?:^HTTP\/|^[A-Z]+ \S+ HTTP\/)(\d+(?:\.\d+)?)/i", $startLine, $matches) && $matches[1] === '1.0') { $versionMatch = preg_match("/(?:^HTTP\/|^[A-Z]+ \S+ HTTP\/)(\d+(?:\.\d+)?)/i", $startLine, $matches);
if ($versionMatch === false) {
throw new \RuntimeException('Unable to parse HTTP start line: '.preg_last_error_msg());
}
if ($versionMatch === 1 && $matches[1] === '1.0') {
// Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0 // Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0
$rawHeaders = preg_replace(Rfc7230::HEADER_FOLD_REGEX, ' ', $rawHeaders); $rawHeaders = preg_replace(Rfc7230::HEADER_FOLD_REGEX, ' ', $rawHeaders);
if ($rawHeaders === null) {
throw new \RuntimeException('Unable to unfold HTTP headers: '.preg_last_error_msg());
}
} }
/** @var array[] $headerLines */ /** @var array[] $headerLines */
$count = preg_match_all(Rfc7230::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER); $count = preg_match_all(Rfc7230::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER);
if ($count === false) {
throw new \RuntimeException('Unable to parse HTTP headers: '.preg_last_error_msg());
}
// If these aren't the same, then one line didn't match and there's an invalid header. // If these aren't the same, then one line didn't match and there's an invalid header.
if ($count !== substr_count($rawHeaders, "\n")) { if ($count !== substr_count($rawHeaders, "\n")) {
// Folding is deprecated, see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4 // Folding is deprecated, see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4
if (preg_match(Rfc7230::HEADER_FOLD_REGEX, $rawHeaders)) { $hasFoldedHeader = preg_match(Rfc7230::HEADER_FOLD_REGEX, $rawHeaders);
if ($hasFoldedHeader === false) {
throw new \RuntimeException('Unable to inspect HTTP header folding: '.preg_last_error_msg());
}
if ($hasFoldedHeader === 1) {
throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding'); throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding');
} }
@ -237,8 +265,10 @@ final class Message
$host = self::getHostFromHeaders($headers); $host = self::getHostFromHeaders($headers);
// If no host is found, then a full URI cannot be constructed. // If no host is found, then a full URI cannot be constructed.
// Collapse leading slashes so an origin-form target cannot be
// parsed as a network-path reference with its own authority.
if ($host === null) { if ($host === null) {
return $path; return self::normalizePathForOriginForm($path);
} }
$scheme = substr($host, -4) === ':443' ? 'https' : 'http'; $scheme = substr($host, -4) === ':443' ? 'https' : 'http';
@ -246,6 +276,15 @@ final class Message
return $scheme.'://'.$host.'/'.ltrim($path, '/'); return $scheme.'://'.$host.'/'.ltrim($path, '/');
} }
private static function normalizePathForOriginForm(string $path): string
{
if (0 === strpos($path, '//')) {
return '/'.ltrim($path, '/');
}
return $path;
}
/** /**
* @param array $headers Array of headers (each value an array). * @param array $headers Array of headers (each value an array).
*/ */
@ -255,7 +294,7 @@ final class Message
// Numeric array keys are converted to int by PHP. // Numeric array keys are converted to int by PHP.
$k = (string) $k; $k = (string) $k;
return strtolower($k) === 'host'; return Utils::asciiToLower($k) === 'host';
}); });
if (!$hostKey) { if (!$hostKey) {
@ -278,8 +317,18 @@ final class Message
public static function parseRequest(string $message): RequestInterface public static function parseRequest(string $message): RequestInterface
{ {
$data = self::parseMessage($message); $data = self::parseMessage($message);
if (strpbrk($data['start-line'], "\r\n") !== false) {
throw new \InvalidArgumentException('Invalid request string');
}
$matches = []; $matches = [];
if (!preg_match('/^[\S]+\s+([a-zA-Z]+:\/\/|\/).*/', $data['start-line'], $matches)) { $requestStartLineMatch = preg_match('/^[\S]+\s+([a-zA-Z]+:\/\/|\/).*/', $data['start-line'], $matches);
if ($requestStartLineMatch === false) {
throw new \RuntimeException('Unable to parse request start line: '.preg_last_error_msg());
}
if ($requestStartLineMatch === 0) {
throw new \InvalidArgumentException('Invalid request string'); throw new \InvalidArgumentException('Invalid request string');
} }
$parts = explode(' ', $data['start-line'], 3); $parts = explode(' ', $data['start-line'], 3);
@ -304,10 +353,20 @@ final class Message
public static function parseResponse(string $message): ResponseInterface public static function parseResponse(string $message): ResponseInterface
{ {
$data = self::parseMessage($message); $data = self::parseMessage($message);
if (strpbrk($data['start-line'], "\r\n") !== false) {
throw new \InvalidArgumentException('Invalid response string');
}
// According to https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2 // According to https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2
// the space between status-code and reason-phrase is required. But // the space between status-code and reason-phrase is required. But
// browsers accept responses without space and reason as well. // browsers accept responses without space and reason as well.
if (!preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/', $data['start-line'])) { $responseStartLineMatch = preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/D', $data['start-line']);
if ($responseStartLineMatch === false) {
throw new \RuntimeException('Unable to parse response start line: '.preg_last_error_msg());
}
if ($responseStartLineMatch === 0) {
throw new \InvalidArgumentException('Invalid response string: '.$data['start-line']); throw new \InvalidArgumentException('Invalid response string: '.$data['start-line']);
} }
$parts = explode(' ', $data['start-line'], 3); $parts = explode(' ', $data['start-line'], 3);

View file

@ -43,6 +43,8 @@ trait MessageTrait
); );
} }
$this->assertProtocolVersion($version);
if ($this->protocol === $version) { if ($this->protocol === $version) {
return $this; return $this;
} }
@ -60,12 +62,12 @@ trait MessageTrait
public function hasHeader($header): bool public function hasHeader($header): bool
{ {
return isset($this->headerNames[strtolower($header)]); return isset($this->headerNames[Utils::asciiToLower($header)]);
} }
public function getHeader($header): array public function getHeader($header): array
{ {
$header = strtolower($header); $header = Utils::asciiToLower($header);
if (!isset($this->headerNames[$header])) { if (!isset($this->headerNames[$header])) {
return []; return [];
@ -101,7 +103,7 @@ trait MessageTrait
} }
} }
$value = $this->normalizeHeaderValue($value); $value = $this->normalizeHeaderValue($value);
$normalized = strtolower($header); $normalized = Utils::asciiToLower($header);
$new = clone $this; $new = clone $this;
if (isset($new->headerNames[$normalized])) { if (isset($new->headerNames[$normalized])) {
@ -133,7 +135,7 @@ trait MessageTrait
} }
} }
$value = $this->normalizeHeaderValue($value); $value = $this->normalizeHeaderValue($value);
$normalized = strtolower($header); $normalized = Utils::asciiToLower($header);
$new = clone $this; $new = clone $this;
if (isset($new->headerNames[$normalized])) { if (isset($new->headerNames[$normalized])) {
@ -152,7 +154,7 @@ trait MessageTrait
*/ */
public function withoutHeader($header): MessageInterface public function withoutHeader($header): MessageInterface
{ {
$normalized = strtolower($header); $normalized = Utils::asciiToLower($header);
if (!isset($this->headerNames[$normalized])) { if (!isset($this->headerNames[$normalized])) {
return $this; return $this;
@ -216,7 +218,7 @@ trait MessageTrait
} }
} }
$value = $this->normalizeHeaderValue($value); $value = $this->normalizeHeaderValue($value);
$normalized = strtolower($header); $normalized = Utils::asciiToLower($header);
if (isset($this->headerNames[$normalized])) { if (isset($this->headerNames[$normalized])) {
$header = $this->headerNames[$normalized]; $header = $this->headerNames[$normalized];
$this->headers[$header] = array_merge($this->headers[$header], $value); $this->headers[$header] = array_merge($this->headers[$header], $value);
@ -273,6 +275,12 @@ trait MessageTrait
)); ));
} }
// Convert non-finite floats explicitly, as implicit coercion of
// NAN emits a warning on PHP 8.5.
if (is_float($value) && !is_finite($value)) {
$value = is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
$trimmed = trim((string) $value, " \t"); $trimmed = trim((string) $value, " \t");
$this->assertValue($trimmed); $this->assertValue($trimmed);
@ -301,6 +309,23 @@ trait MessageTrait
} }
} }
/**
* @param mixed $version
*/
private function assertProtocolVersion($version): void
{
if (is_string($version)) {
$this->assertNoLineSeparators($version, 'Protocol version');
}
}
private function assertNoLineSeparators(string $value, string $field): void
{
if (strpbrk($value, "\r\n") !== false) {
throw new \InvalidArgumentException($field.' must not contain CR or LF characters.');
}
}
/** /**
* @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
* *

View file

@ -1300,6 +1300,6 @@ final class MimeType
*/ */
public static function fromExtension(string $extension): ?string public static function fromExtension(string $extension): ?string
{ {
return self::MIME_TYPES[strtolower($extension)] ?? null; return self::MIME_TYPES[Utils::asciiToLower($extension)] ?? null;
} }
} }

View file

@ -26,8 +26,9 @@ final class MultipartStream implements StreamInterface
* @param array $elements Array of associative arrays, each containing a * @param array $elements Array of associative arrays, each containing a
* required "name" key mapping to the form field, * required "name" key mapping to the form field,
* name, a required "contents" key mapping to any * name, a required "contents" key mapping to any
* non-array value accepted by Utils::streamFor(), * non-array value accepted by Utils::streamFor()
* or an array for nested expansion. * (non-string scalar field values are cast to
* string), or an array for nested expansion.
* Optional keys include "headers" (associative * Optional keys include "headers" (associative
* array of custom headers) and "filename" (string * array of custom headers) and "filename" (string
* to send as the filename in the part). * to send as the filename in the part).
@ -77,7 +78,7 @@ final class MultipartStream implements StreamInterface
$str .= "{$key}: {$value}\r\n"; $str .= "{$key}: {$value}\r\n";
} }
return "--{$this->boundary}\r\n".trim($str)."\r\n\r\n"; return "--{$this->boundary}\r\n".trim($str, " \n\r\t\0\x0B")."\r\n\r\n";
} }
/** /**
@ -124,7 +125,27 @@ final class MultipartStream implements StreamInterface
return; return;
} }
$element['contents'] = Utils::streamFor($element['contents']); $contents = $element['contents'];
if (is_scalar($contents) && !is_string($contents)) {
// Multipart field values are byte strings on the wire, so finite
// numeric and boolean field values are cast to string here rather
// than tripping streamFor()'s non-string-scalar deprecation. Non-finite
// floats are deprecated and normalized here too, so the deprecation is
// reported against MultipartStream instead of transitively through
// streamFor().
if (is_float($contents) && !is_finite($contents)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.12',
'Passing a non-finite float as multipart contents is deprecated; guzzlehttp/psr7 3.0 rejects non-finite floats.'
);
$contents = is_nan($contents) ? 'NAN' : ($contents > 0 ? 'INF' : '-INF');
}
$contents = (string) $contents;
}
$element['contents'] = Utils::streamFor($contents);
if (empty($element['filename'])) { if (empty($element['filename'])) {
$uri = $element['contents']->getMetadata('uri'); $uri = $element['contents']->getMetadata('uri');
@ -206,9 +227,9 @@ final class MultipartStream implements StreamInterface
*/ */
private static function getHeader(array $headers, string $key): ?string private static function getHeader(array $headers, string $key): ?string
{ {
$lowercaseHeader = strtolower($key); $lowercaseHeader = Utils::asciiToLower($key);
foreach ($headers as $k => $v) { foreach ($headers as $k => $v) {
if (strtolower((string) $k) === $lowercaseHeader) { if (Utils::asciiToLower((string) $k) === $lowercaseHeader) {
return $v; return $v;
} }
} }

View file

@ -96,7 +96,7 @@ final class Query
$k = $encoder((string) $k); $k = $encoder((string) $k);
if (!is_array($v)) { if (!is_array($v)) {
$qs .= $k; $qs .= $k;
$v = is_bool($v) ? $castBool($v) : $v; $v = is_bool($v) ? $castBool($v) : self::normalizeNonFiniteFloat($v);
if ($v !== null) { if ($v !== null) {
$qs .= '='.$encoder((string) $v); $qs .= '='.$encoder((string) $v);
} }
@ -104,7 +104,7 @@ final class Query
} else { } else {
foreach ($v as $vv) { foreach ($v as $vv) {
$qs .= $k; $qs .= $k;
$vv = is_bool($vv) ? $castBool($vv) : $vv; $vv = is_bool($vv) ? $castBool($vv) : self::normalizeNonFiniteFloat($vv);
if ($vv !== null) { if ($vv !== null) {
$qs .= '='.$encoder((string) $vv); $qs .= '='.$encoder((string) $vv);
} }
@ -115,4 +115,27 @@ final class Query
return $qs ? (string) substr($qs, 0, -1) : ''; return $qs ? (string) substr($qs, 0, -1) : '';
} }
/**
* Converts non-finite floats to the strings PHP coerces them to, as
* implicit coercion of NAN emits a warning on PHP 8.5.
*
* @param mixed $value
*
* @return mixed
*/
private static function normalizeNonFiniteFloat($value)
{
if (is_float($value) && !is_finite($value)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.12',
'Passing a non-finite float to Query::build() is deprecated; guzzlehttp/psr7 3.0 rejects non-finite floats.'
);
return is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
return $value;
}
} }

View file

@ -40,12 +40,14 @@ class Request implements RequestInterface
string $version = '1.1' string $version = '1.1'
) { ) {
$this->assertMethod($method); $this->assertMethod($method);
$this->assertProtocolVersion($version);
if (!$uri instanceof UriInterface) { if (!$uri instanceof UriInterface) {
$uri = new Uri($uri); $uri = new Uri($uri);
} }
self::warnOnMethodCasingChange($method); self::warnOnMethodCasingChange($method);
$this->method = strtoupper($method); $this->method = Utils::asciiToUpper($method);
$this->uri = $uri; $this->uri = $uri;
$this->setHeaders($headers); $this->setHeaders($headers);
$this->protocol = $version; $this->protocol = $version;
@ -78,7 +80,13 @@ class Request implements RequestInterface
public function withRequestTarget($requestTarget): RequestInterface public function withRequestTarget($requestTarget): RequestInterface
{ {
if (preg_match('#\s#', $requestTarget)) { $hasWhitespace = preg_match('#\s#', $requestTarget);
if ($hasWhitespace === false) {
throw new \RuntimeException('Unable to validate request target: '.preg_last_error_msg());
}
if ($hasWhitespace === 1) {
throw new InvalidArgumentException( throw new InvalidArgumentException(
'Invalid request target provided; cannot contain whitespace' 'Invalid request target provided; cannot contain whitespace'
); );
@ -100,7 +108,7 @@ class Request implements RequestInterface
$this->assertMethod($method); $this->assertMethod($method);
self::warnOnMethodCasingChange($method); self::warnOnMethodCasingChange($method);
$new = clone $this; $new = clone $this;
$new->method = strtoupper($method); $new->method = Utils::asciiToUpper($method);
return $new; return $new;
} }
@ -170,11 +178,13 @@ class Request implements RequestInterface
if (!is_string($method) || $method === '') { if (!is_string($method) || $method === '') {
throw new InvalidArgumentException('Method must be a non-empty string.'); throw new InvalidArgumentException('Method must be a non-empty string.');
} }
$this->assertNoLineSeparators($method, 'Method');
} }
private static function warnOnMethodCasingChange(string $method): void private static function warnOnMethodCasingChange(string $method): void
{ {
if ($method !== strtoupper($method)) { if ($method !== Utils::asciiToUpper($method)) {
\trigger_deprecation( \trigger_deprecation(
'guzzlehttp/psr7', 'guzzlehttp/psr7',
'2.11', '2.11',

View file

@ -99,6 +99,7 @@ class Response implements ResponseInterface
?string $reason = null ?string $reason = null
) { ) {
$this->assertStatusCodeRange($status); $this->assertStatusCodeRange($status);
$this->assertProtocolVersion($version);
$this->statusCode = $status; $this->statusCode = $status;
@ -108,11 +109,14 @@ class Response implements ResponseInterface
$this->setHeaders($headers); $this->setHeaders($headers);
if ($reason == '' && isset(self::PHRASES[$this->statusCode])) { if ($reason == '' && isset(self::PHRASES[$this->statusCode])) {
$this->reasonPhrase = self::PHRASES[$this->statusCode]; $reasonPhrase = self::PHRASES[$this->statusCode];
} else { } else {
$this->reasonPhrase = (string) $reason; $reasonPhrase = (string) $reason;
} }
$this->assertNoLineSeparators($reasonPhrase, 'Reason phrase');
$this->reasonPhrase = $reasonPhrase;
$this->protocol = $version; $this->protocol = $version;
} }
@ -155,7 +159,9 @@ class Response implements ResponseInterface
if ($reasonPhrase == '' && isset(self::PHRASES[$new->statusCode])) { if ($reasonPhrase == '' && isset(self::PHRASES[$new->statusCode])) {
$reasonPhrase = self::PHRASES[$new->statusCode]; $reasonPhrase = self::PHRASES[$new->statusCode];
} }
$new->reasonPhrase = (string) $reasonPhrase; $reasonPhrase = (string) $reasonPhrase;
$this->assertNoLineSeparators($reasonPhrase, 'Reason phrase');
$new->reasonPhrase = $reasonPhrase;
return $new; return $new;
} }

View file

@ -68,7 +68,13 @@ final class Rfc7230
private static function isValidHostHeaderHost(string $host): bool private static function isValidHostHeaderHost(string $host): bool
{ {
if (preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host)) { $invalidHost = preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host);
if ($invalidHost === false) {
return false;
}
if ($invalidHost === 1) {
return false; return false;
} }

View file

@ -165,7 +165,7 @@ class ServerRequest extends Request implements ServerRequestInterface
*/ */
public static function fromGlobals(): ServerRequestInterface public static function fromGlobals(): ServerRequestInterface
{ {
$method = strtoupper(self::getServerParam('REQUEST_METHOD') ?? 'GET'); $method = Utils::asciiToUpper(self::getServerParam('REQUEST_METHOD') ?? 'GET');
$headers = self::removeInvalidHostHeader(self::getAllHeaders()); $headers = self::removeInvalidHostHeader(self::getAllHeaders());
$uri = self::getUriFromGlobals(); $uri = self::getUriFromGlobals();
$body = new CachingStream(new LazyOpenStream('php://input', 'r+')); $body = new CachingStream(new LazyOpenStream('php://input', 'r+'));
@ -220,7 +220,7 @@ class ServerRequest extends Request implements ServerRequestInterface
private static function removeInvalidHostHeader(array $headers): array private static function removeInvalidHostHeader(array $headers): array
{ {
foreach ($headers as $name => $value) { foreach ($headers as $name => $value) {
if (strtolower((string) $name) !== 'host') { if (Utils::asciiToLower((string) $name) !== 'host') {
continue; continue;
} }
@ -269,7 +269,7 @@ class ServerRequest extends Request implements ServerRequestInterface
} }
$serverPort = self::getServerParam('SERVER_PORT'); $serverPort = self::getServerParam('SERVER_PORT');
if (!$hasPort && $serverPort !== null && preg_match('/^[+-]?\d+$/', $serverPort) === 1) { if (!$hasPort && $serverPort !== null && preg_match('/^[+-]?\d+$/D', $serverPort) === 1) {
$uri = $uri->withPort((int) $serverPort); $uri = $uri->withPort((int) $serverPort);
} }

View file

@ -99,12 +99,30 @@ class Uri implements UriInterface, \JsonSerializable
return self::parsePathNoSchemeReference($url); return self::parsePathNoSchemeReference($url);
} }
// Preserve bracketed IPv6 literals before encoding, including dotted IPv4 tails. // Preserve bracketed IPv6 literals before encoding, including dotted IPv4
// tails. DEL (\x7F) is excluded so a raw-DEL host falls through to the
// general path and is rejected rather than silently mutated by parse_url().
$prefix = ''; $prefix = '';
if (preg_match('%^([0-9A-Za-z+.-]+://\[[0-9:.a-fA-F]+\])(.*?)$%', $url, $matches)) { $ipv6Prefix = preg_match('%\A([0-9A-Za-z+.-]+://\[[^\]\x00-\x20\x7F/?#@]+\])(.*)\z%s', $url, $matches);
if ($ipv6Prefix === false) {
return false;
}
if ($ipv6Prefix === 1) {
/** @var array{0:string, 1:string, 2:string} $matches */ /** @var array{0:string, 1:string, 2:string} $matches */
$suffix = $matches[2];
// After the bracketed host only an optional numeric port and/or a
// path, query, or fragment may follow. Anything else (for example
// `:80@evil` or `:80x`) would let parse_url() reinterpret a
// different host.
if (preg_match('%\A(?::[0-9]*)?(?:[/?#].*)?\z%s', $suffix) !== 1) {
return false;
}
$prefix = $matches[1]; $prefix = $matches[1];
$url = $matches[2]; $url = $suffix;
} }
/** @var string|null */ /** @var string|null */
@ -371,12 +389,38 @@ class Uri implements UriInterface, \JsonSerializable
$result = self::getFilteredQueryString($uri, array_keys($keyValueArray)); $result = self::getFilteredQueryString($uri, array_keys($keyValueArray));
foreach ($keyValueArray as $key => $value) { foreach ($keyValueArray as $key => $value) {
$result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null); $result[] = self::generateQueryString((string) $key, $value !== null ? self::stringifyQueryValue($value) : null);
} }
return $uri->withQuery(implode('&', $result)); return $uri->withQuery(implode('&', $result));
} }
/**
* Stringifies a non-null query value, deprecating non-string values that
* guzzlehttp/psr7 3.0 will reject. Non-finite floats are normalized to the
* strings PHP coerces them to, as implicit coercion of NAN emits a warning
* on PHP 8.5.
*
* @param mixed $value
*/
private static function stringifyQueryValue($value): string
{
if (!is_string($value)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.12',
'Passing %s to Uri::withQueryValues() is deprecated; cast it to a string. guzzlehttp/psr7 3.0 will only accept string or null query values.',
\gettype($value)
);
if (is_float($value) && !is_finite($value)) {
return is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
}
}
return (string) $value;
}
/** /**
* Creates a URI from a hash of `parse_url` components. * Creates a URI from a hash of `parse_url` components.
* *
@ -410,7 +454,27 @@ class Uri implements UriInterface, \JsonSerializable
return; return;
} }
if (preg_match('/[\x00-\x20\x7F]/', $host)) { // Reject control characters and URI authority delimiters so getHost()
// cannot disagree with the on-wire authority.
$invalidHost = preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host);
if ($invalidHost === false) {
throw new \RuntimeException('Unable to validate URI host: '.preg_last_error_msg());
}
if ($invalidHost === 1) {
throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host));
}
if (strpos($host, '[') !== false || strpos($host, ']') !== false) {
if ($host[0] !== '[' || substr($host, -1) !== ']') {
throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host));
}
return;
}
if (strpos($host, ':') !== false) {
throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host)); throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host));
} }
} }
@ -632,7 +696,7 @@ class Uri implements UriInterface, \JsonSerializable
throw new \InvalidArgumentException('Scheme must be a string'); throw new \InvalidArgumentException('Scheme must be a string');
} }
$scheme = \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); $scheme = Utils::asciiToLower($scheme);
if ($scheme !== '' && !preg_match('/^[a-z][a-z0-9.+-]*$/D', $scheme)) { if ($scheme !== '' && !preg_match('/^[a-z][a-z0-9.+-]*$/D', $scheme)) {
\trigger_deprecation( \trigger_deprecation(
@ -657,10 +721,10 @@ class Uri implements UriInterface, \JsonSerializable
throw new \InvalidArgumentException('User info must be a string'); throw new \InvalidArgumentException('User info must be a string');
} }
return preg_replace_callback( return $this->filterComponent(
'/(?:[^%'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/', '/(?:[^%'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'], $component,
$component 'Unable to filter URI user info'
); );
} }
@ -675,7 +739,7 @@ class Uri implements UriInterface, \JsonSerializable
throw new \InvalidArgumentException('Host must be a string'); throw new \InvalidArgumentException('Host must be a string');
} }
$host = \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); $host = Utils::asciiToLower($host);
self::assertValidHost($host); self::assertValidHost($host);
return $host; return $host;
@ -759,10 +823,10 @@ class Uri implements UriInterface, \JsonSerializable
throw new \InvalidArgumentException('Path must be a string'); throw new \InvalidArgumentException('Path must be a string');
} }
return preg_replace_callback( return $this->filterComponent(
'/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/', '/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'], $path,
$path 'Unable to filter URI path'
); );
} }
@ -779,13 +843,24 @@ class Uri implements UriInterface, \JsonSerializable
throw new \InvalidArgumentException('Query and fragment must be a string'); throw new \InvalidArgumentException('Query and fragment must be a string');
} }
return preg_replace_callback( return $this->filterComponent(
'/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', '/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'], $str,
$str 'Unable to filter URI query or fragment'
); );
} }
private function filterComponent(string $pattern, string $component, string $context): string
{
$filtered = preg_replace_callback($pattern, [$this, 'rawurlencodeMatchZero'], $component);
if ($filtered === null) {
throw new \RuntimeException($context.': '.preg_last_error_msg());
}
return $filtered;
}
private function rawurlencodeMatchZero(array $match): string private function rawurlencodeMatchZero(array $match): string
{ {
return rawurlencode($match[0]); return rawurlencode($match[0]);

View file

@ -19,7 +19,7 @@ final class UriComparator
*/ */
public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool
{ {
if (\strcasecmp($original->getHost(), $modified->getHost()) !== 0) { if (!Utils::caselessEquals($original->getHost(), $modified->getHost())) {
return true; return true;
} }

View file

@ -150,7 +150,13 @@ final class UriNormalizer
} }
if ($flags & self::REMOVE_DUPLICATE_SLASHES) { if ($flags & self::REMOVE_DUPLICATE_SLASHES) {
$uri = $uri->withPath(preg_replace('#//++#', '/', $uri->getPath())); $path = preg_replace('#//++#', '/', $uri->getPath());
if ($path === null) {
throw new \RuntimeException('Unable to remove duplicate slashes from URI path: '.preg_last_error_msg());
}
$uri = $uri->withPath($path);
} }
if ($flags & self::SORT_QUERY_PARAMETERS && $uri->getQuery() !== '') { if ($flags & self::SORT_QUERY_PARAMETERS && $uri->getQuery() !== '') {
@ -186,7 +192,7 @@ final class UriNormalizer
$regex = '/(?:%[A-Fa-f0-9]{2})++/'; $regex = '/(?:%[A-Fa-f0-9]{2})++/';
$callback = function (array $match): string { $callback = function (array $match): string {
return strtoupper($match[0]); return Utils::asciiToUpper($match[0]);
}; };
return $uri return $uri
@ -217,7 +223,7 @@ final class UriNormalizer
$normalized = preg_replace_callback($regex, $callback, $component); $normalized = preg_replace_callback($regex, $callback, $component);
if ($normalized === null) { if ($normalized === null) {
throw new \RuntimeException('Unable to normalize URI component percent-encoding'); throw new \RuntimeException('Unable to normalize URI component percent-encoding: '.preg_last_error_msg());
} }
return $normalized; return $normalized;

View file

@ -10,6 +10,65 @@ use Psr\Http\Message\UriInterface;
final class Utils final class Utils
{ {
/**
* Converts ASCII uppercase letters in a string to lowercase.
*
* Unlike strtolower(), which honors LC_CTYPE before PHP 8.2, the
* conversion is locale-independent and leaves every non-ASCII byte
* unchanged, as HTTP protocol elements require.
*/
public static function asciiToLower(string $string): string
{
return strtr($string, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
}
/**
* Converts ASCII lowercase letters in a string to uppercase.
*
* Unlike strtoupper(), which honors LC_CTYPE before PHP 8.2, the
* conversion is locale-independent and leaves every non-ASCII byte
* unchanged, as HTTP protocol elements require.
*/
public static function asciiToUpper(string $string): string
{
return strtr($string, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ');
}
/**
* Converts the first character of a string to uppercase when it is an
* ASCII lowercase letter.
*
* Unlike ucfirst(), which honors LC_CTYPE before PHP 8.2, the conversion
* is locale-independent and leaves every non-ASCII byte unchanged, as
* HTTP protocol elements require.
*/
public static function asciiUcFirst(string $string): string
{
if ($string === '') {
return '';
}
return self::asciiToUpper($string[0]).substr($string, 1);
}
/**
* Checks whether the haystack contains the needle, comparing ASCII
* letters case-insensitively and without locale sensitivity.
*/
public static function caselessContains(string $haystack, string $needle): bool
{
return str_contains(self::asciiToLower($haystack), self::asciiToLower($needle));
}
/**
* Checks whether two strings are equal, comparing ASCII letters
* case-insensitively and without locale sensitivity.
*/
public static function caselessEquals(string $left, string $right): bool
{
return self::asciiToLower($left) === self::asciiToLower($right);
}
/** /**
* Remove the items given by the keys, case insensitively from the data. * Remove the items given by the keys, case insensitively from the data.
* *
@ -20,11 +79,11 @@ final class Utils
$result = []; $result = [];
foreach ($keys as &$key) { foreach ($keys as &$key) {
$key = strtolower((string) $key); $key = self::asciiToLower((string) $key);
} }
foreach ($data as $k => $v) { foreach ($data as $k => $v) {
if (!in_array(strtolower((string) $k), $keys)) { if (!in_array(self::asciiToLower((string) $k), $keys)) {
$result[$k] = $v; $result[$k] = $v;
} }
} }
@ -212,7 +271,7 @@ final class Utils
if ($host !== '') { if ($host !== '') {
if (isset($changes['set_headers']) && is_array($changes['set_headers'])) { if (isset($changes['set_headers']) && is_array($changes['set_headers'])) {
foreach (array_keys($changes['set_headers']) as $header) { foreach (array_keys($changes['set_headers']) as $header) {
if (strtolower((string) $header) === 'host') { if (self::asciiToLower((string) $header) === 'host') {
throw new \InvalidArgumentException( throw new \InvalidArgumentException(
'Cannot modify request with both a URI containing a host and an explicit Host header.' 'Cannot modify request with both a URI containing a host and an explicit Host header.'
); );
@ -248,7 +307,7 @@ final class Utils
$hasHost = false; $hasHost = false;
foreach (array_keys($headers) as $header) { foreach (array_keys($headers) as $header) {
if (strtolower((string) $header) === 'host') { if (self::asciiToLower((string) $header) === 'host') {
$hasHost = true; $hasHost = true;
break; break;
} }
@ -284,7 +343,7 @@ final class Utils
$addedHeaders = []; $addedHeaders = [];
foreach ($headers as $header => $value) { foreach ($headers as $header => $value) {
$header = (string) $header; $header = (string) $header;
$normalized = strtolower($header); $normalized = self::asciiToLower($header);
if (isset($addedHeaders[$normalized])) { if (isset($addedHeaders[$normalized])) {
/** @var RequestInterface */ /** @var RequestInterface */
@ -462,6 +521,9 @@ final class Utils
* in subsequent reads. String inputs are always treated as string bodies, * in subsequent reads. String inputs are always treated as string bodies,
* even when they name callable functions. * even when they name callable functions.
* *
* Passing a non-string scalar (`int`, `float`, or `bool`) is deprecated; cast
* it to a string instead. guzzlehttp/psr7 3.0 will reject non-string scalars.
*
* @param resource|string|int|float|bool|StreamInterface|callable|\Iterator|null $resource Entity body data * @param resource|string|int|float|bool|StreamInterface|callable|\Iterator|null $resource Entity body data
* @param array{size?: int, metadata?: array} $options Additional options * @param array{size?: int, metadata?: array} $options Additional options
* *
@ -470,6 +532,22 @@ final class Utils
public static function streamFor($resource = '', array $options = []): StreamInterface public static function streamFor($resource = '', array $options = []): StreamInterface
{ {
if (is_scalar($resource)) { if (is_scalar($resource)) {
if (!is_string($resource)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.12',
'Passing %s to Utils::streamFor() is deprecated; cast it to a string. guzzlehttp/psr7 3.0 will only accept string, resource, StreamInterface, Stringable, Iterator, callable, or null.',
\gettype($resource)
);
if (is_float($resource) && !is_finite($resource)) {
// Normalized only to avoid PHP 8.5's (string) NAN warning
// while deprecated; 3.0 rejects non-finite floats with every
// other non-string scalar.
$resource = is_nan($resource) ? 'NAN' : ($resource > 0 ? 'INF' : '-INF');
}
}
$stream = self::tryFopen('php://temp', 'r+'); $stream = self::tryFopen('php://temp', 'r+');
if ($resource !== '') { if ($resource !== '') {
fwrite($stream, (string) $resource); fwrite($stream, (string) $resource);

View file

@ -1,123 +0,0 @@
============
jmespath.php
============
JMESPath (pronounced "jaymz path") allows you to declaratively specify how to
extract elements from a JSON document. *jmespath.php* allows you to use
JMESPath in PHP applications with PHP data structures. It requires PHP 7.2.5 or
greater and can be installed through `Composer <http://getcomposer.org/doc/00-intro.md>`_
using the ``mtdowling/jmespath.php`` package.
.. code-block:: php
require 'vendor/autoload.php';
$expression = 'foo.*.baz';
$data = [
'foo' => [
'bar' => ['baz' => 1],
'bam' => ['baz' => 2],
'boo' => ['baz' => 3]
]
];
JmesPath\search($expression, $data);
// Returns: [1, 2, 3]
- `JMESPath Tutorial <http://jmespath.org/tutorial.html>`_
- `JMESPath Grammar <http://jmespath.org/specification.html#grammar>`_
- `JMESPath Python library <https://github.com/jmespath/jmespath.py>`_
PHP Usage
=========
The ``JmesPath\search`` function can be used in most cases when using the
library. This function utilizes a JMESPath runtime based on your environment.
The runtime utilized can be configured using environment variables and may at
some point in the future automatically utilize a C extension if available.
.. code-block:: php
$result = JmesPath\search($expression, $data);
// or, if you require PSR-4 compliance.
$result = JmesPath\Env::search($expression, $data);
Runtimes
--------
jmespath.php utilizes *runtimes*. There are currently two runtimes:
AstRuntime and CompilerRuntime.
AstRuntime is utilized by ``JmesPath\search()`` and ``JmesPath\Env::search()``
by default.
AstRuntime
~~~~~~~~~~
The AstRuntime will parse an expression, cache the resulting AST in memory,
and interpret the AST using an external tree visitor. AstRuntime provides a
good general approach for interpreting JMESPath expressions that have a low to
moderate level of reuse.
.. code-block:: php
$runtime = new JmesPath\AstRuntime();
$runtime('foo.bar', ['foo' => ['bar' => 'baz']]);
// > 'baz'
CompilerRuntime
~~~~~~~~~~~~~~~
``JmesPath\CompilerRuntime`` provides the most performance for
applications that have a moderate to high level of reuse of JMESPath
expressions. The CompilerRuntime will walk a JMESPath AST and emit PHP source
code, resulting in anywhere from 7x to 60x speed improvements.
Compiling JMESPath expressions to source code is a slower process than just
walking and interpreting a JMESPath AST (via the AstRuntime). However,
running the compiled JMESPath code results in much better performance than
walking an AST. This essentially means that there is a warm-up period when
using the ``CompilerRuntime``, but after the warm-up period, it will provide
much better performance.
Use the CompilerRuntime if you know that you will be executing JMESPath
expressions more than once or if you can pre-compile JMESPath expressions
before executing them (for example, server-side applications).
.. code-block:: php
// Note: The cache directory argument is optional.
$runtime = new JmesPath\CompilerRuntime('/path/to/compile/folder');
$runtime('foo.bar', ['foo' => ['bar' => 'baz']]);
// > 'baz'
Environment Variables
^^^^^^^^^^^^^^^^^^^^^
You can utilize the CompilerRuntime in ``JmesPath\search()`` by setting
the ``JP_PHP_COMPILE`` environment variable to "on" or to a directory
on disk used to store cached expressions.
Testing
=======
A comprehensive list of test cases can be found at
https://github.com/jmespath/jmespath.php/tree/master/tests/compliance.
These compliance tests are utilized by jmespath.php to ensure consistency with
other implementations, and can serve as examples of the language.
jmespath.php is tested using PHPUnit. In order to run the tests, you need to
first install the dependencies using Composer as described in the *Installation*
section. Next you just need to run the tests via make:
.. code-block:: bash
make test
You can run a suite of performance tests as well:
.. code-block:: bash
make perf

View file

@ -9,7 +9,6 @@ class AstRuntime
private $parser; private $parser;
private $interpreter; private $interpreter;
private $cache = []; private $cache = [];
private $cachedCount = 0;
public function __construct( public function __construct(
?Parser $parser = null, ?Parser $parser = null,
@ -34,10 +33,9 @@ class AstRuntime
public function __invoke($expression, $data) public function __invoke($expression, $data)
{ {
if (!isset($this->cache[$expression])) { if (!isset($this->cache[$expression])) {
// Clear the AST cache when it hits 1024 entries // Clear the AST cache when it already holds 1024 entries.
if (++$this->cachedCount > 1024) { if (count($this->cache) >= 1024) {
$this->cache = []; $this->cache = [];
$this->cachedCount = 0;
} }
$this->cache[$expression] = $this->parser->parse($expression); $this->cache[$expression] = $this->parser->parse($expression);
} }

View file

@ -8,11 +8,14 @@ namespace JmesPath;
* logic to determine the filename: * logic to determine the filename:
* *
* 1. Start with the string "jmespath_" * 1. Start with the string "jmespath_"
* 2. Append the MD5 checksum of the expression. * 2. Append the MD5 checksum of the expression salted with the PHP version
* and compiled-cache epoch.
* 3. Append ".php" * 3. Append ".php"
*/ */
class CompilerRuntime class CompilerRuntime
{ {
const CACHE_VERSION = 5;
private $parser; private $parser;
private $compiler; private $compiler;
private $cacheDir; private $cacheDir;
@ -51,7 +54,7 @@ class CompilerRuntime
*/ */
public function __invoke($expression, $data) public function __invoke($expression, $data)
{ {
$functionName = 'jmespath_' . md5($expression); $functionName = self::functionName($expression);
if (!function_exists($functionName)) { if (!function_exists($functionName)) {
$filename = "{$this->cacheDir}/{$functionName}.php"; $filename = "{$this->cacheDir}/{$functionName}.php";
@ -64,6 +67,23 @@ class CompilerRuntime
return $functionName($this->interpreter, $data); return $functionName($this->interpreter, $data);
} }
/**
* @internal Shared with DebugRuntime so cache naming cannot drift.
*/
public static function functionName($expression)
{
return 'jmespath_' . md5(
'jmespath:' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION
. ':' . self::CACHE_VERSION . ':' . $expression
);
}
/** @internal */
public function getCacheDir()
{
return $this->cacheDir;
}
private function compile($filename, $expression, $functionName) private function compile($filename, $expression, $functionName)
{ {
$code = $this->compiler->visit( $code = $this->compiler->visit(
@ -72,12 +92,26 @@ class CompilerRuntime
$expression $expression
); );
if (!file_put_contents($filename, $code)) { $tempFile = $filename . '.' . bin2hex(random_bytes(12)) . '.tmp';
if (!file_put_contents($tempFile, $code)) {
throw new \RuntimeException(sprintf( throw new \RuntimeException(sprintf(
'Unable to write the compiled PHP code to: %s (%s)', 'Unable to write the compiled PHP code to: %s (%s)',
$filename, $tempFile,
var_export(error_get_last(), true) var_export(error_get_last(), true)
)); ));
} }
if (!rename($tempFile, $filename)) {
@unlink($tempFile);
if (!file_exists($filename)) {
throw new \RuntimeException(
"Unable to move the compiled PHP code to: {$filename}"
);
}
// Another process won the race; its file is equivalent.
} elseif (function_exists('opcache_invalidate')) {
opcache_invalidate($filename, true);
}
} }
} }

View file

@ -83,12 +83,12 @@ class DebugRuntime
private function dumpCompiledCode($expression) private function dumpCompiledCode($expression)
{ {
fwrite($this->out, "Code\n========\n\n"); fwrite($this->out, "Code\n========\n\n");
$dir = sys_get_temp_dir(); $functionName = CompilerRuntime::functionName($expression);
$hash = md5($expression); $filename = $this->runtime->getCacheDir() . '/' . $functionName . '.php';
$functionName = "jmespath_{$hash}";
$filename = "{$dir}/{$functionName}.php";
fwrite($this->out, "File: {$filename}\n\n"); fwrite($this->out, "File: {$filename}\n\n");
fprintf($this->out, file_get_contents($filename)); fwrite($this->out, is_file($filename)
? file_get_contents($filename)
: "(not present: {$functionName} was already loaded in this process, likely from another cache directory)\n");
} }
private function debugCallback(callable $debugFn, $expression, $data) private function debugCallback(callable $debugFn, $expression, $data)

View file

@ -57,7 +57,10 @@ final class Env
public static function cleanCompileDir() public static function cleanCompileDir()
{ {
$total = 0; $total = 0;
$compileDir = self::getEnvVariable(self::COMPILE_DIR) ?: sys_get_temp_dir(); $compileDir = self::getEnvVariable(self::COMPILE_DIR);
if ($compileDir === 'on' || !$compileDir) {
$compileDir = sys_get_temp_dir();
}
foreach (glob("{$compileDir}/jmespath_*.php") as $file) { foreach (glob("{$compileDir}/jmespath_*.php") as $file) {
$total++; $total++;

View file

@ -60,14 +60,20 @@ class FnDispatcher
{ {
$this->validate('contains', $args, [['string', 'array'], ['any']]); $this->validate('contains', $args, [['string', 'array'], ['any']]);
if (is_array($args[0])) { if (is_array($args[0])) {
return in_array($args[1], $args[0]); foreach ($args[0] as $value) {
} elseif (is_string($args[1])) { if (Utils::isEqual($value, $args[1])) {
return mb_strpos($args[0], $args[1], 0, 'UTF-8') !== false; return true;
} else {
return null;
} }
} }
return false;
}
return is_string($args[1])
? mb_strpos($args[0], $args[1], 0, 'UTF-8') !== false
: false;
}
private function fn_ends_with(array $args) private function fn_ends_with(array $args)
{ {
$this->validate('ends_with', $args, [['string'], ['string']]); $this->validate('ends_with', $args, [['string'], ['string']]);
@ -100,7 +106,7 @@ class FnDispatcher
$fn = function ($a, $b, $i) use ($args) { $fn = function ($a, $b, $i) use ($args) {
return $i ? ($a . $args[0] . $b) : $b; return $i ? ($a . $args[0] . $b) : $b;
}; };
return $this->reduce('join:0', $args[1], ['string'], $fn); return $args[1] ? $this->reduce('join:0', $args[1], ['string'], $fn) : '';
} }
private function fn_keys(array $args) private function fn_keys(array $args)
@ -118,8 +124,8 @@ class FnDispatcher
private function fn_max(array $args) private function fn_max(array $args)
{ {
$this->validate('max', $args, [['array']]); $this->validate('max', $args, [['array']]);
$fn = function ($a, $b) { $fn = function ($a, $b, $i) {
return $a >= $b ? $a : $b; return $i && self::compareValues($a, $b) >= 0 ? $a : $b;
}; };
return $this->reduce('max:0', $args[0], ['number', 'string'], $fn); return $this->reduce('max:0', $args[0], ['number', 'string'], $fn);
} }
@ -128,10 +134,21 @@ class FnDispatcher
{ {
$this->validate('max_by', $args, [['array'], ['expression']]); $this->validate('max_by', $args, [['array'], ['expression']]);
$expr = $this->wrapExpression('max_by:1', $args[1], ['number', 'string']); $expr = $this->wrapExpression('max_by:1', $args[1], ['number', 'string']);
$fn = function ($carry, $item, $index) use ($expr) { $carryKey = null;
return $index $fn = function ($carry, $item, $index) use ($expr, &$carryKey) {
? ($expr($carry) >= $expr($item) ? $carry : $item) if (!$index) {
: $item; return $item;
}
if ($index === 1) {
$carryKey = $expr($carry);
}
$itemKey = $expr($item);
$this->validateSeq('max_by:0', ['number', 'string'], $carryKey, $itemKey);
if (self::compareValues($carryKey, $itemKey) >= 0) {
return $carry;
}
$carryKey = $itemKey;
return $item;
}; };
return $this->reduce('max_by:1', $args[0], ['any'], $fn); return $this->reduce('max_by:1', $args[0], ['any'], $fn);
} }
@ -140,7 +157,7 @@ class FnDispatcher
{ {
$this->validate('min', $args, [['array']]); $this->validate('min', $args, [['array']]);
$fn = function ($a, $b, $i) { $fn = function ($a, $b, $i) {
return $i && $a <= $b ? $a : $b; return $i && self::compareValues($a, $b) <= 0 ? $a : $b;
}; };
return $this->reduce('min:0', $args[0], ['number', 'string'], $fn); return $this->reduce('min:0', $args[0], ['number', 'string'], $fn);
} }
@ -149,9 +166,21 @@ class FnDispatcher
{ {
$this->validate('min_by', $args, [['array'], ['expression']]); $this->validate('min_by', $args, [['array'], ['expression']]);
$expr = $this->wrapExpression('min_by:1', $args[1], ['number', 'string']); $expr = $this->wrapExpression('min_by:1', $args[1], ['number', 'string']);
$i = -1; $carryKey = null;
$fn = function ($a, $b) use ($expr, &$i) { $fn = function ($carry, $item, $index) use ($expr, &$carryKey) {
return ++$i ? ($expr($a) <= $expr($b) ? $a : $b) : $b; if (!$index) {
return $item;
}
if ($index === 1) {
$carryKey = $expr($carry);
}
$itemKey = $expr($item);
$this->validateSeq('min_by:0', ['number', 'string'], $carryKey, $itemKey);
if (self::compareValues($carryKey, $itemKey) <= 0) {
return $carry;
}
$carryKey = $itemKey;
return $item;
}; };
return $this->reduce('min_by:1', $args[0], ['any'], $fn); return $this->reduce('min_by:1', $args[0], ['any'], $fn);
} }
@ -162,7 +191,7 @@ class FnDispatcher
if (is_array($args[0])) { if (is_array($args[0])) {
return array_reverse($args[0]); return array_reverse($args[0]);
} elseif (is_string($args[0])) { } elseif (is_string($args[0])) {
return strrev($args[0]); return implode('', array_reverse(mb_str_split($args[0], 1, 'UTF-8')));
} else { } else {
throw new \RuntimeException('Cannot reverse provided argument'); throw new \RuntimeException('Cannot reverse provided argument');
} }
@ -174,7 +203,7 @@ class FnDispatcher
$fn = function ($a, $b) { $fn = function ($a, $b) {
return Utils::add($a, $b); return Utils::add($a, $b);
}; };
return $this->reduce('sum:0', $args[0], ['number'], $fn); return $args[0] ? $this->reduce('sum:0', $args[0], ['number'], $fn) : 0;
} }
private function fn_sort(array $args) private function fn_sort(array $args)
@ -183,7 +212,7 @@ class FnDispatcher
$valid = ['string', 'number']; $valid = ['string', 'number'];
return Utils::stableSort($args[0], function ($a, $b) use ($valid) { return Utils::stableSort($args[0], function ($a, $b) use ($valid) {
$this->validateSeq('sort:0', $valid, $a, $b); $this->validateSeq('sort:0', $valid, $a, $b);
return strnatcmp($a, $b); return self::compareValues($a, $b);
}); });
} }
@ -198,7 +227,7 @@ class FnDispatcher
$va = $expr($a); $va = $expr($a);
$vb = $expr($b); $vb = $expr($b);
$this->validateSeq('sort_by:0', $valid, $va, $vb); $this->validateSeq('sort_by:0', $valid, $va, $vb);
return strnatcmp($va, $vb); return self::compareValues($va, $vb);
} }
); );
} }
@ -236,14 +265,53 @@ class FnDispatcher
{ {
$this->validateArity('to_number', count($args), 1); $this->validateArity('to_number', count($args), 1);
$value = $args[0]; $value = $args[0];
$type = Utils::type($value);
if ($type == 'number') { if (Utils::type($value) == 'number') {
return $value; return $value;
} elseif ($type == 'string' && is_numeric($value)) { }
return mb_strpos($value, '.', 0, 'UTF-8') ? (float) $value : (int) $value;
} else { if (!is_string($value)) {
return null; return null;
} }
return $this->parseJsonNumber($value);
}
/**
* Parses a string conforming to the JSON number grammar (RFC 8259) into
* an int when exactly representable, otherwise a float. Returns null for
* non-conforming or non-finite input.
*/
private function parseJsonNumber($value)
{
if (!preg_match('/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/D', $value)) {
return null;
}
if (preg_match('/^-?(?:0|[1-9][0-9]*)$/D', $value)) {
return $this->parseJsonInteger($value);
}
$number = (float) $value;
return is_finite($number) ? $number : null;
}
private function parseJsonInteger($value)
{
$negative = $value[0] === '-';
$digits = $negative ? substr($value, 1) : $value;
$limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
if (strlen($digits) < strlen($limit)
|| (strlen($digits) === strlen($limit) && strcmp($digits, $limit) <= 0)
) {
return (int) $value;
}
$number = (float) $value;
return is_finite($number) ? $number : null;
} }
private function fn_values(array $args) private function fn_values(array $args)
@ -272,7 +340,7 @@ class FnDispatcher
private function fn_map(array $args) private function fn_map(array $args)
{ {
$this->validate('map', $args, [['expression'], ['any']]); $this->validate('map', $args, [['expression'], ['array']]);
$result = []; $result = [];
foreach ($args[1] as $a) { foreach ($args[1] as $a) {
$result[] = $args[0]($a); $result[] = $args[0]($a);
@ -354,6 +422,21 @@ class FnDispatcher
} }
} }
/**
* Compares two values of the same JMESPath type.
*
* @param mixed $a Value A
* @param mixed $b Value B
*
* @return int Negative if $a < $b, zero if equal, positive if $a > $b.
*/
private static function compareValues($a, $b)
{
return Utils::type($a) === 'string'
? strcmp((string) $a, (string) $b)
: ($a <=> $b);
}
/** /**
* Reduces and validates an array of values to a single value using a fn. * Reduces and validates an array of values to a single value using a fn.
* *

View file

@ -298,9 +298,10 @@ class Lexer
$buffer .= $current; $buffer .= $current;
$current = next($chars); $current = next($chars);
} while ($current !== false && isset($this->numbers[$current])); } while ($current !== false && isset($this->numbers[$current]));
$value = $this->parseIndexNumber($buffer);
$tokens[] = [ $tokens[] = [
'type' => self::T_NUMBER, 'type' => $value === null ? self::T_UNKNOWN : self::T_NUMBER,
'value' => (int)$buffer, 'value' => $value === null ? $buffer : $value,
'pos' => $start 'pos' => $start
]; ];
@ -417,6 +418,30 @@ class Lexer
return ['type' => $type, 'value' => $buffer, 'pos' => $position]; return ['type' => $type, 'value' => $buffer, 'pos' => $position];
} }
/**
* Parses a bare index/slice integer token ("-"? digit+). Returns null
* when the buffer is a lone "-" or the value cannot be represented as a
* PHP integer.
*/
private function parseIndexNumber($buffer)
{
if ($buffer === '-') {
return null;
}
$negative = $buffer[0] === '-';
$digits = ltrim($negative ? substr($buffer, 1) : $buffer, '0') ?: '0';
$limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX;
if (strlen($digits) > strlen($limit)
|| (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)
) {
return null;
}
return (int) $buffer;
}
/** /**
* Parses a JSON token or sets the token type to "unknown" on error. * Parses a JSON token or sets the token type to "unknown" on error.
* *

View file

@ -5,7 +5,7 @@ use JmesPath\Lexer as T;
/** /**
* JMESPath Pratt parser * JMESPath Pratt parser
* @link http://hall.org.ua/halls/wizzard/pdf/Vaughan.Pratt.TDOP.pdf * @link https://dl.acm.org/doi/10.1145/512927.512931
*/ */
class Parser class Parser
{ {
@ -22,6 +22,8 @@ class Parser
T::T_EOF => 0, T::T_EOF => 0,
T::T_QUOTED_IDENTIFIER => 0, T::T_QUOTED_IDENTIFIER => 0,
T::T_IDENTIFIER => 0, T::T_IDENTIFIER => 0,
T::T_UNKNOWN => 0,
T::T_LITERAL => 0,
T::T_RBRACKET => 0, T::T_RBRACKET => 0,
T::T_RPAREN => 0, T::T_RPAREN => 0,
T::T_COMMA => 0, T::T_COMMA => 0,
@ -272,6 +274,10 @@ class Parser
private function led_lparen(array $left) private function led_lparen(array $left)
{ {
if (!isset($left['type'], $left['value']) || $left['type'] !== 'field') {
throw $this->syntax('Invalid function name');
}
$args = []; $args = [];
$this->next(); $this->next();
@ -347,6 +353,10 @@ class Parser
if ($this->token['type'] == T::T_LBRACKET) { if ($this->token['type'] == T::T_LBRACKET) {
$this->next(); $this->next();
return $this->parseMultiSelectList(); return $this->parseMultiSelectList();
} elseif ($this->token['type'] == T::T_LBRACE) {
// Like the multi-select list above, a multi-select hash ends any
// projection: tokens that follow apply to the projected list.
return $this->nud_lbrace();
} }
return $this->expr($bp); return $this->expr($bp);

View file

@ -16,6 +16,7 @@ class SyntaxErrorException extends \InvalidArgumentException
array $token, array $token,
$expression $expression
) { ) {
$token += ['pos' => mb_strlen($expression, 'UTF-8'), 'value' => null];
$message = sprintf("Syntax error at character %d\n", max($token['pos'], 0)) $message = sprintf("Syntax error at character %d\n", max($token['pos'], 0))
. $expression . "\n" . str_repeat(' ', max($token['pos'], 0)) . "^\n"; . $expression . "\n" . str_repeat(' ', max($token['pos'], 0)) . "^\n";
$message .= !is_array($expectedTypesOrMessage) $message .= !is_array($expectedTypesOrMessage)

View file

@ -107,7 +107,7 @@ class TreeCompiler
return $this return $this
->write('%s = $value;', $a) ->write('%s = $value;', $a)
->dispatch($node['children'][0]) ->dispatch($node['children'][0])
->write('if (!$value && $value !== "0" && $value !== 0) {') ->write('if (!Utils::isTruthy($value)) {')
->indent() ->indent()
->write('$value = %s;', $a) ->write('$value = %s;', $a)
->dispatch($node['children'][1]) ->dispatch($node['children'][1])
@ -121,7 +121,7 @@ class TreeCompiler
return $this return $this
->write('%s = $value;', $a) ->write('%s = $value;', $a)
->dispatch($node['children'][0]) ->dispatch($node['children'][0])
->write('if ($value || $value === "0" || $value === 0) {') ->write('if (Utils::isTruthy($value)) {')
->indent() ->indent()
->write('$value = %s;', $a) ->write('$value = %s;', $a)
->dispatch($node['children'][1]) ->dispatch($node['children'][1])
@ -257,8 +257,8 @@ class TreeCompiler
} }
return $this->write( return $this->write(
'$value = Fd::getInstance()->__invoke("%s", %s);', '$value = Fd::getInstance()->__invoke(%s, %s);',
$node['value'], $args var_export($node['value'], true), $args
); );
} }
@ -332,7 +332,7 @@ class TreeCompiler
->write(''); ->write('');
if (!isset($node['from'])) { if (!isset($node['from'])) {
$this->write('if (!is_array($value) || !($value instanceof \stdClass)) { $value = null; }'); $this->write('if (!is_array($value) && !($value instanceof \stdClass)) { $value = null; }');
} elseif ($node['from'] == 'object') { } elseif ($node['from'] == 'object') {
$this->write('if (!Utils::isObject($value)) { $value = null; }'); $this->write('if (!Utils::isObject($value)) { $value = null; }');
} elseif ($node['from'] == 'array') { } elseif ($node['from'] == 'array') {

View file

@ -69,7 +69,8 @@ class TreeInterpreter
case 'projection': case 'projection':
$left = $this->dispatch($node['children'][0], $value); $left = $this->dispatch($node['children'][0], $value);
switch ($node['from']) { $from = isset($node['from']) ? $node['from'] : null;
switch ($from) {
case 'object': case 'object':
if (!Utils::isObject($left)) { if (!Utils::isObject($left)) {
return null; return null;
@ -81,7 +82,7 @@ class TreeInterpreter
} }
break; break;
default: default:
if (!is_array($left) || !($left instanceof \stdClass)) { if (!is_array($left) && !($left instanceof \stdClass)) {
return null; return null;
} }
} }

View file

@ -22,7 +22,7 @@ class Utils
public static function isTruthy($value) public static function isTruthy($value)
{ {
if (!$value) { if (!$value) {
return $value === 0 || $value === '0'; return $value === 0 || $value === 0.0 || $value === '0';
} elseif ($value instanceof \stdClass) { } elseif ($value instanceof \stdClass) {
return (bool) get_object_vars($value); return (bool) get_object_vars($value);
} else { } else {
@ -58,7 +58,8 @@ class Utils
return count($arg) == 0 || $arg->offsetExists(0) return count($arg) == 0 || $arg->offsetExists(0)
? 'array' ? 'array'
: 'object'; : 'object';
} elseif (method_exists($arg, '__toString')) { } elseif (is_object($arg)) {
if (method_exists($arg, '__toString')) {
return 'string'; return 'string';
} }
@ -67,6 +68,11 @@ class Utils
); );
} }
throw new \InvalidArgumentException(
'Unable to determine JMESPath type from ' . gettype($arg)
);
}
/** /**
* Determine if the provided value is a JMESPath compatible object. * Determine if the provided value is a JMESPath compatible object.
* *
@ -106,7 +112,10 @@ class Utils
} }
/** /**
* JSON aware value comparison function. * JSON-semantic equality: one number type, structural comparison for arrays
* and objects, and no object key-order sensitivity.
* Empty arrays and empty objects compare equal because PHP cannot represent
* that distinction after associative JSON decoding.
* *
* @param mixed $a First value to compare * @param mixed $a First value to compare
* @param mixed $b Second value to compare * @param mixed $b Second value to compare
@ -115,15 +124,38 @@ class Utils
*/ */
public static function isEqual($a, $b) public static function isEqual($a, $b)
{ {
if ($a === $b) { $typeA = self::type($a);
return true; $typeB = self::type($b);
} elseif ($a instanceof \stdClass) {
return self::isEqual((array) $a, $b); if ($typeA !== $typeB) {
} elseif ($b instanceof \stdClass) { return ($typeA === 'array' || $typeA === 'object')
return self::isEqual($a, (array) $b); && ($typeB === 'array' || $typeB === 'object')
} else { && (array) $a === []
&& (array) $b === [];
}
if ($typeA === 'number') {
return $a == $b;
}
if ($typeA === 'array' || $typeA === 'object') {
$a = (array) $a;
$b = (array) $b;
if (count($a) !== count($b)) {
return false; return false;
} }
foreach ($a as $key => $value) {
if (!array_key_exists($key, $b) || !self::isEqual($value, $b[$key])) {
return false;
}
}
return true;
}
return $a === $b;
} }
/** /**
@ -160,7 +192,7 @@ class Utils
* @param callable $sortFn Callable used to sort values * @param callable $sortFn Callable used to sort values
* *
* @return array Returns the sorted array * @return array Returns the sorted array
* @link http://en.wikipedia.org/wiki/Schwartzian_transform * @link https://en.wikipedia.org/wiki/Schwartzian_transform
*/ */
public static function stableSort(array $data, callable $sortFn) public static function stableSort(array $data, callable $sortFn)
{ {
@ -192,7 +224,7 @@ class Utils
*/ */
public static function slice($value, $start = null, $stop = null, $step = 1) public static function slice($value, $start = null, $stop = null, $step = 1)
{ {
if (!is_array($value) && !is_string($value)) { if (!is_string($value) && !self::isArray($value)) {
throw new \InvalidArgumentException('Expects string or array'); throw new \InvalidArgumentException('Expects string or array');
} }
@ -239,7 +271,10 @@ class Utils
private static function sliceIndices($subject, $start, $stop, $step) private static function sliceIndices($subject, $start, $stop, $step)
{ {
$type = gettype($subject); $type = gettype($subject);
$len = $type == 'string' ? mb_strlen($subject, 'UTF-8') : count($subject); if ($type == 'string') {
$subject = mb_str_split($subject, 1, 'UTF-8');
}
$len = count($subject);
list($start, $stop, $step) = self::adjustSlice($len, $start, $stop, $step); list($start, $stop, $step) = self::adjustSlice($len, $start, $stop, $step);
$result = []; $result = [];

View file

@ -1,5 +0,0 @@
CHANGELOG
=========
The changelog is maintained for all Symfony contracts at the following URL:
https://github.com/symfony/contracts/blob/main/CHANGELOG.md

View file

@ -1,26 +0,0 @@
Symfony Deprecation Contracts
=============================
A generic function and convention to trigger deprecation notices.
This package provides a single global function named `trigger_deprecation()` that triggers silenced deprecation notices.
By using a custom PHP error handler such as the one provided by the Symfony ErrorHandler component,
the triggered deprecations can be caught and logged for later discovery, both on dev and prod environments.
The function requires at least 3 arguments:
- the name of the Composer package that is triggering the deprecation
- the version of the package that introduced the deprecation
- the message of the deprecation
- more arguments can be provided: they will be inserted in the message using `printf()` formatting
Example:
```php
trigger_deprecation('symfony/blockchain', '8.9', 'Using "%s" is deprecated, use "%s" instead.', 'bitcoin', 'fabcoin');
```
This will generate the following message:
`Since symfony/blockchain 8.9: Using "bitcoin" is deprecated, use "fabcoin" instead.`
While not recommended, the deprecation notices can be completely ignored by declaring an empty
`function trigger_deprecation() {}` in your application.

View file

@ -1,97 +0,0 @@
CHANGELOG
=========
8.1
---
* Deprecate calling `Filesystem::mirror()` with option `copy_on_windows`, use option `follow_symlinks` instead.
7.1
---
* Add the `Filesystem::readFile()` method
7.0
---
* Add argument `$lock` to `Filesystem::appendToFile()`
5.4
---
* Add `Path` class
* Add `$lock` argument to `Filesystem::appendToFile()`
5.0.0
-----
* `Filesystem::dumpFile()` and `appendToFile()` don't accept arrays anymore
4.4.0
-----
* support for passing a `null` value to `Filesystem::isAbsolutePath()` is deprecated and will be removed in 5.0
* `tempnam()` now accepts a third argument `$suffix`.
4.3.0
-----
* support for passing arrays to `Filesystem::dumpFile()` is deprecated and will be removed in 5.0
* support for passing arrays to `Filesystem::appendToFile()` is deprecated and will be removed in 5.0
4.0.0
-----
* removed `LockHandler`
* Support for passing relative paths to `Filesystem::makePathRelative()` has been removed.
3.4.0
-----
* support for passing relative paths to `Filesystem::makePathRelative()` is deprecated and will be removed in 4.0
3.3.0
-----
* added `appendToFile()` to append contents to existing files
3.2.0
-----
* added `readlink()` as a platform independent method to read links
3.0.0
-----
* removed `$mode` argument from `Filesystem::dumpFile()`
2.8.0
-----
* added tempnam() a stream aware version of PHP's native tempnam()
2.6.0
-----
* added LockHandler
2.3.12
------
* deprecated dumpFile() file mode argument.
2.3.0
-----
* added the dumpFile() method to atomically write files
2.2.0
-----
* added a delete option for the mirror() method
2.1.0
-----
* 24eb396 : BC Break : mkdir() function now throws exception in case of failure instead of returning Boolean value
* created the component

View file

@ -1,23 +0,0 @@
Filesystem Component
====================
The Filesystem component provides basic utilities for the filesystem.
Sponsor
-------
This package is looking for a [backer][1].
Help Symfony by [sponsoring][3] its development!
Resources
---------
* [Documentation](https://symfony.com/doc/current/components/filesystem.html)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)
[1]: https://symfony.com/backers
[3]: https://symfony.com/sponsor

View file

@ -1,12 +0,0 @@
Symfony Polyfill / Ctype
========================
This component provides `ctype_*` functions to users who run php versions without the ctype extension.
More information can be found in the
[main Polyfill README](https://github.com/symfony/polyfill/blob/main/README.md).
License
=======
This library is released under the [MIT license](LICENSE).

View file

@ -157,10 +157,6 @@ final class Mbstring
$fromEncoding = 'UTF-8'; $fromEncoding = 'UTF-8';
} }
if ($fromEncoding === $toEncoding) {
return $s;
}
return self::iconv($fromEncoding, $toEncoding, $s); return self::iconv($fromEncoding, $toEncoding, $s);
} }
@ -877,18 +873,20 @@ final class Mbstring
} }
/** @return string|false */ /** @return string|false */
public static function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = \STR_PAD_RIGHT, ?string $encoding = null) public static function mb_scrub(?string $string, ?string $encoding = null): string
{ {
if (!\in_array($pad_type, [\STR_PAD_RIGHT, \STR_PAD_LEFT, \STR_PAD_BOTH], true)) { if (null === $encoding) {
if (\PHP_VERSION_ID < 80000) { $encoding = self::mb_internal_encoding();
trigger_error('mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH', \E_USER_WARNING); } elseif (!self::assertEncoding($encoding, 'mb_scrub(): Argument #2 ($encoding) must be a valid encoding, "%s" given')) {
return false; return false;
} }
throw new \ValueError('mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH'); return self::mb_convert_encoding((string) $string, $encoding, $encoding);
} }
/** @return string|false */
public static function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = \STR_PAD_RIGHT, ?string $encoding = null)
{
if (null === $encoding) { if (null === $encoding) {
$encoding = self::mb_internal_encoding(); $encoding = self::mb_internal_encoding();
} elseif (!self::assertEncoding($encoding, 'mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given')) { } elseif (!self::assertEncoding($encoding, 'mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given')) {
@ -905,6 +903,16 @@ final class Mbstring
throw new \ValueError('mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string'); throw new \ValueError('mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string');
} }
if (!\in_array($pad_type, [\STR_PAD_RIGHT, \STR_PAD_LEFT, \STR_PAD_BOTH], true)) {
if (\PHP_VERSION_ID < 80000) {
trigger_error('mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH', \E_USER_WARNING);
return false;
}
throw new \ValueError('mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH');
}
$paddingRequired = $length - self::mb_strlen($string, $encoding); $paddingRequired = $length - self::mb_strlen($string, $encoding);
if ($paddingRequired < 1) { if ($paddingRequired < 1) {

View file

@ -1,13 +0,0 @@
Symfony Polyfill / Mbstring
===========================
This component provides a partial, native PHP implementation for the
[Mbstring](https://php.net/mbstring) extension.
More information can be found in the
[main Polyfill README](https://github.com/symfony/polyfill/blob/main/README.md).
License
=======
This library is released under the [MIT license](LICENSE).

View file

@ -122,7 +122,7 @@ if (!function_exists('mb_chr')) {
function mb_chr($codepoint, $encoding = null) { return p\Mbstring::mb_chr($codepoint, $encoding); } function mb_chr($codepoint, $encoding = null) { return p\Mbstring::mb_chr($codepoint, $encoding); }
} }
if (!function_exists('mb_scrub')) { if (!function_exists('mb_scrub')) {
function mb_scrub($string, $encoding = null) { $encoding = null === $encoding ? mb_internal_encoding() : $encoding; return mb_convert_encoding($string, $encoding, $encoding); } function mb_scrub($string, $encoding = null) { return p\Mbstring::mb_scrub($string, $encoding); }
} }
if (!function_exists('mb_str_split')) { if (!function_exists('mb_str_split')) {
function mb_str_split($string, $length = 1, $encoding = null) { return p\Mbstring::mb_str_split($string, $length, $encoding); } function mb_str_split($string, $length = 1, $encoding = null) { return p\Mbstring::mb_str_split($string, $length, $encoding); }

View file

@ -122,7 +122,7 @@ if (!function_exists('mb_chr')) {
function mb_chr(?int $codepoint, ?string $encoding = null): string|false { return p\Mbstring::mb_chr((int) $codepoint, $encoding); } function mb_chr(?int $codepoint, ?string $encoding = null): string|false { return p\Mbstring::mb_chr((int) $codepoint, $encoding); }
} }
if (!function_exists('mb_scrub')) { if (!function_exists('mb_scrub')) {
function mb_scrub(?string $string, ?string $encoding = null): string { $encoding ??= mb_internal_encoding(); return mb_convert_encoding((string) $string, $encoding, $encoding); } function mb_scrub(?string $string, ?string $encoding = null): string { return p\Mbstring::mb_scrub($string, $encoding); }
} }
if (!function_exists('mb_str_split')) { if (!function_exists('mb_str_split')) {
function mb_str_split(?string $string, ?int $length = 1, ?string $encoding = null): array { return p\Mbstring::mb_str_split((string) $string, (int) $length, $encoding); } function mb_str_split(?string $string, ?int $length = 1, ?string $encoding = null): array { return p\Mbstring::mb_str_split((string) $string, (int) $length, $encoding); }

View file

@ -1,25 +0,0 @@
Symfony Polyfill / Php80
========================
This component provides features added to PHP 8.0 core:
- [`Stringable`](https://php.net/stringable) interface
- [`fdiv`](https://php.net/fdiv)
- [`ValueError`](https://php.net/valueerror) class
- [`UnhandledMatchError`](https://php.net/unhandledmatcherror) class
- `FILTER_VALIDATE_BOOL` constant
- [`get_debug_type`](https://php.net/get_debug_type)
- [`PhpToken`](https://php.net/phptoken) class
- [`preg_last_error_msg`](https://php.net/preg_last_error_msg)
- [`str_contains`](https://php.net/str_contains)
- [`str_starts_with`](https://php.net/str_starts_with)
- [`str_ends_with`](https://php.net/str_ends_with)
- [`get_resource_id`](https://php.net/get_resource_id)
More information can be found in the
[main Polyfill README](https://github.com/symfony/polyfill/blob/main/README.md).
License
=======
This library is released under the [MIT license](LICENSE).