1, 2 => 3, 3 => 1, 4 => 2, 6 => 4, )[ ord( $h[9] ) ]; // Adam7 overhead is bounded above by 8 bytes/pixel plus pass-row overhead for supported <=8-bit PNG. $bound = $input['width'] * $input['height'] * 8 + ( $input['height'] + $input['width'] ) * 16 + 1024; $raw = gzuncompress( $packed, $bound ); if ( ! is_string( $raw ) || strlen( $raw ) > $bound ) { throw new \RuntimeException( 'invalid_png' ); } $passes = 0 === ord( $h[12] ) ? array( array( 0, 0, 1, 1 ) ) : array( array( 0, 0, 8, 8 ), array( 4, 0, 8, 8 ), array( 0, 4, 4, 8 ), array( 2, 0, 4, 4 ), array( 0, 2, 2, 4 ), array( 1, 0, 2, 2 ), array( 0, 1, 1, 2 ) ); $offset = 0; foreach ( $passes as $pass ) { list($x, $y, $dx, $dy) = $pass; $pw = max( 0, (int) ceil( ( $input['width'] - $x ) / $dx ) ); $ph = max( 0, (int) ceil( ( $input['height'] - $y ) / $dy ) ); if ( ! $pw || ! $ph ) { continue; }$rowbytes = (int) ceil( $pw * $channels * $depth / 8 );for ( $yy = 0; $yy < $ph; ++$yy ) { if ( $offset + $rowbytes + 1 > strlen( $raw ) || ord( $raw[ $offset ] ) > 4 ) { throw new \RuntimeException( 'invalid_png' ); }$offset += $rowbytes + 1; } } if ( $offset !== strlen( $raw ) ) { throw new \RuntimeException( 'invalid_png' ); } $palettes = array_values( array_filter( $chunks, static fn( $c ) => 'PLTE' === $c['type'] ) ); if ( count( $palettes ) > 1 || ( 3 === ord( $h[9] ) && ( ! $palettes || strlen( $palettes[0]['data'] ) < 3 || strlen( $palettes[0]['data'] ) > 3 * ( 2 ** $depth ) || strlen( $palettes[0]['data'] ) % 3 ) ) ) { throw new \RuntimeException( 'invalid_png' ); } $compressed = gzcompress( $raw, 'fast' === $recipe['effort'] ? 6 : 9 ); if ( ! is_string( $compressed ) || gzuncompress( $compressed, $bound ) !== $raw ) { throw new \RuntimeException( 'pixel_or_source_mismatch' ); } $out = substr( $data, 0, 8 ); $done = false; foreach ( $chunks as $c ) { if ( 'IDAT' === $c['type'] ) { if ( ! $done ) { $out .= ImageInput::png_chunk( 'IDAT', $compressed ); $done = true; } } else { $out .= ImageInput::png_chunk( $c['type'], $c['data'] ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. if ( Reviewed_Store::file_put_contents( $temporary, $out, LOCK_EX ) !== strlen( $out ) ) { throw new \RuntimeException( 'candidate_write_failed' ); } return array( 'fidelity' => 'lossless', 'verification' => 'Identical decompressed PNG scanlines; all other chunks preserved except explicitly removed EXIF GPS.', 'quality_check' => array( 'method' => 'identical decompressed IDAT stream', 'exact' => true, 'passed' => true, ), 'metadata_handling' => $recipe['metadata'], ); } } /** * JPEG, WebP and AVIF copies through Imagick or GD, each decoded again and checked against the picture. */ final class RasterEncoder implements EncoderAdapter { /** * An encoder for one library and one format. * * @param string $backend imagick or gd. * @param string $output jpeg, webp or avif. */ public function __construct( private string $backend, private string $output ) {} /** * The encoder's name. * * @return string The library and format, such as gd-avif. */ public function id(): string { return $this->backend . '-' . $this->output; } /** * The library's version, recorded with each copy. * * @return string The version. */ public function version(): string { return 'imagick' === $this->backend ? ( \Imagick::getVersion()['versionString'] ?? 'unknown' ) : ( gd_info()['GD Version'] ?? 'unknown' ); } /** * The format the encoder writes. * * @return string jpeg, webp or avif. */ public function format(): string { return $this->output; } /** * Decode a picture with a library for the pixel check: one image, at most 8 bits, in sRGB, RGB or grey. * * @param string $data The picture's bytes. * @param string $backend imagick or gd. * @return mixed The decoded picture (Imagick or GD). * @throws \RuntimeException With unsupported_color_or_frames, orientation_held or decode_failed. */ public static function decode( string $data, string $backend ) { if ( 'imagick' === $backend ) { $a = new \Imagick(); $a->readImageBlob( $data ); if ( 1 !== $a->getNumberImages() || $a->getImageDepth() > 8 || ! in_array( $a->getImageColorspace(), array( \Imagick::COLORSPACE_SRGB, \Imagick::COLORSPACE_RGB, \Imagick::COLORSPACE_GRAY ), true ) ) { $a->clear(); throw new \RuntimeException( 'unsupported_color_or_frames' ); } if ( ! in_array( $a->getImageOrientation(), array( \Imagick::ORIENTATION_UNDEFINED, \Imagick::ORIENTATION_TOPLEFT ), true ) ) { $a->clear(); throw new \RuntimeException( 'orientation_held' ); } return $a; } $a = imagecreatefromstring( $data ); if ( ! $a ) { throw new \RuntimeException( 'decode_failed' ); } if ( ! imageistruecolor( $a ) ) { imagepalettetotruecolor( $a ); } imagealphablending( $a, false ); imagesavealpha( $a, true ); return $a; } /** * Write a JPEG, WebP or AVIF candidate and check it against the picture at the recipe's fidelity. * * @param array $input The picture's input record. * @param string $temporary Where to write the candidate. * @param array $recipe The compression recipe. * @param string $fidelity lossless or the photo fidelity. * @return array The candidate's measurements and its check. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public function encode( array $input, string $temporary, array $recipe, string $fidelity ): array { if ( 'imagick' === $this->backend ? ! $input['imagick_safe'] : ! $input['modern_safe'] ) { throw new \RuntimeException( 'metadata_preserved_native_only' ); } // D-253: only a WebP or AVIF copy leaves a JPEG's comment and print resolution out; any other output keeps holding the file. if ( ! empty( $input['display_notes'] ) && ! in_array( $this->output, array( 'webp', 'avif' ), true ) ) { throw new \RuntimeException( 'metadata_preserved_native_only' ); } if ( 'gd' === $this->backend && $input['alpha'] ) { throw new \RuntimeException( 'alpha_requires_exact_imagick' ); } if ( 'lossless' === $fidelity && ! in_array( $this->output, array( 'png', 'webp' ), true ) ) { throw new \RuntimeException( 'unsupported_lossless_target' ); } $data = ImageInput::sanitized( $input, $recipe ); $a = null; $b = null; $c = null; try { $a = self::decode( $data, $this->backend ); $q = Policy::quality( $this->output, $fidelity ); if ( 'imagick' === $this->backend ) { $profiles = $a->getImageProfiles( '*', true ); if ( array_diff( array_keys( $profiles ), array( 'icc', 'icm', 'exif', 'xmp' ) ) ) { throw new \RuntimeException( 'metadata_preserved_native_only' ); } if ( 'avif' === $this->output && $profiles ) { throw new \RuntimeException( 'metadata_preserved_native_only' ); } $b = clone $a; $b->setImageFormat( $this->output ); $b->setImageDepth( 8 ); if ( ! empty( $input['display_notes'] ) ) { $b->deleteImageProperty( 'comment' ); } if ( 'webp' === $this->output ) { $b->setOption( 'webp:lossless', 'lossless' === $fidelity ? 'true' : 'false' ); $b->setOption( 'webp:exact', 'true' ); $b->setOption( 'webp:method', 'fast' === $recipe['effort'] ? '1' : '4' ); $b->setOption( 'webp:alpha-quality', '100' ); } if ( 'jpeg' === $this->output ) { $b->setImageInterlaceScheme( \Imagick::INTERLACE_PLANE ); $b->setSamplingFactors( array( '1x1', '1x1', '1x1' ) ); } if ( 'avif' === $this->output ) { $b->setOption( 'heic:speed', 'fast' === $recipe['effort'] ? '8' : '6' ); $b->setOption( 'heic:chroma', '444' ); $b->setOption( 'heic:preserve-orientation', 'true' ); } $b->setImageCompressionQuality( 'lossless' === $fidelity ? 100 : $q ); if ( ! $b->writeImage( $temporary ) ) { throw new \RuntimeException( 'candidate_write_failed' ); } $b->clear(); $b->readImage( $temporary ); $out_profiles = $b->getImageProfiles( '*', true ); foreach ( $profiles as $name => $value ) { if ( ! isset( $out_profiles[ $name ] ) || ! hash_equals( hash( 'sha256', $value ), hash( 'sha256', $out_profiles[ $name ] ) ) ) { throw new \RuntimeException( 'metadata_roundtrip_failed' ); } } } else { if ( 'jpeg' === $this->output ) { imageinterlace( $a, true ); } $ok = match ( $this->output ) { 'webp' => imagewebp( $a, $temporary, 'lossless' === $fidelity ? IMG_WEBP_LOSSLESS : $q ), 'jpeg' => imagejpeg( $a, $temporary, $q ), 'avif' => imageavif( $a, $temporary, $q, 'fast' === $recipe['effort'] ? 8 : 6 ), default => false, }; if ( ! $ok || ! is_file( $temporary ) ) { throw new \RuntimeException( 'candidate_write_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. $b = imagecreatefromstring( file_get_contents( $temporary ) ); if ( ! $b ) { throw new \RuntimeException( 'candidate_decode_failed' ); } } $check = PixelCheck::compare( $a, $b, $this->backend, $fidelity ); if ( ! $check['passed'] ) { throw new \RuntimeException( 'quality_gate_failed' ); } if ( 'webp' === $this->output && 'lossless' === $fidelity ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. $chunks = ImageInput::webp_chunks( file_get_contents( $temporary ) ); $types = array_column( $chunks, 'type' ); if ( ! in_array( 'VP8L', $types, true ) || in_array( 'VP8 ', $types, true ) ) { throw new \RuntimeException( 'not_verified_lossless_webp' ); } } return array( 'fidelity' => $fidelity, 'quality_check' => $check, 'quality_setting' => 'lossless' === $fidelity ? 'explicit lossless' : $q, 'verification' => 'lossless' === $fidelity ? 'Exact decoded RGBA verified.' : 'Sampled luma SSIM and color-error checks passed; review the visual preview.', 'metadata_handling' => $recipe['metadata'], ); } finally { if ( 'imagick' === $this->backend ) { foreach ( array( $a, $b, $c ) as $im ) { if ( $im instanceof \Imagick ) { $im->clear(); } } } unset( $a, $b, $c ); } } } /** * 1.9.0 (D-500 gap 7 step 2): an animated GIF as an animated lossless WebP. Imagick composes each image on the full canvas, * as browsers show it, and encodes the first one whole and each later one's changed area as a lossless WebP picture; * Image Manager writes the animation around them (Imagick 6's own animated WebP writer drops transparency): each area * replaces the canvas without blending and stays until the next, the animation repeats for ever. Every area is decoded * again and compared with the GIF's composed image, every visible pixel exact. */ final class AnimatedWebpEncoder implements EncoderAdapter { /** * The encoder's name. * * @return string imagick-webp-animated. */ public function id(): string { return 'imagick-webp-animated'; } /** * Imagick's version, recorded with each copy. * * @return string The version. */ public function version(): string { return \Imagick::getVersion()['versionString'] ?? 'unknown'; } /** * The format the encoder writes. * * @return string webp. */ public function format(): string { return 'webp'; } /** * Write an animated lossless WebP of an animated GIF that repeats for ever, and check every frame. * * @param array $input The GIF's input record (read with its animation). * @param string $temporary Where to write the candidate. * @param array $recipe The compression recipe. * @param string $fidelity Always lossless here. * @return array The candidate's measurements and its check. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public function encode( array $input, string $temporary, array $recipe, string $fidelity ): array { if ( empty( $input['animated'] ) || 'image/gif' !== $input['mime'] ) { throw new \RuntimeException( 'unsupported_color_or_frames' ); } if ( ! $input['imagick_safe'] ) { throw new \RuntimeException( 'metadata_preserved_native_only' ); } if ( 'lossless' !== $fidelity ) { throw new \RuntimeException( 'unsupported_lossless_target' ); } $w = $input['width']; $h = $input['height']; $a = null; $c = null; $frames = array(); $alpha = false; $area = 0; try { $a = new \Imagick(); $a->readImageBlob( ImageInput::sanitized( $input, $recipe ) ); if ( $a->getNumberImages() !== $input['frames'] ) { throw new \RuntimeException( 'unsupported_color_or_frames' ); } $c = $a->coalesceImages(); $prev = ''; for ( $i = 0; $i < $input['frames']; ++$i ) { $c->setIteratorIndex( $i ); if ( $c->getImageWidth() !== $w || $c->getImageHeight() !== $h ) { throw new \RuntimeException( 'candidate_dimensions_or_mime' ); } $ms = (int) round( $c->getImageDelay() * 1000 / max( 1, $c->getImageTicksPerSecond() ) ); $now = self::rgba( $c ); $box = '' === $prev ? array( 0, 0, $w - 1, $h - 1 ) : self::changed( $prev, $now, $w ); $prev = $now; if ( ! $box ) { // The same picture again: the one before is shown longer. $frames[ count( $frames ) - 1 ]['ms'] += $ms; continue; } // ANMF offsets are stored halved: an area starts on even coordinates. $x = $box[0] - $box[0] % 2; $y = $box[1] - $box[1] % 2; $r = $c->getImage(); try { $r->cropImage( $box[2] - $x + 1, $box[3] - $y + 1, $x, $y ); $r->setImagePage( 0, 0, 0, 0 ); // Composing can leave the canvas transparent where no image of the GIF drew (as browsers show it) with // Imagick's alpha flag off, and the WebP would then be written opaque: the flag is switched on, the // pixels stay as they are. $r->setImageAlphaChannel( \Imagick::ALPHACHANNEL_ACTIVATE ); list( $vp8l, $has_alpha ) = self::lossless_area( $r, $recipe ); $alpha = $alpha || $has_alpha; $area += $r->getImageWidth() * $r->getImageHeight(); $frames[] = array( 'x' => $x, 'y' => $y, 'w' => $r->getImageWidth(), 'h' => $r->getImageHeight(), 'ms' => $ms, 'vp8l' => $vp8l, ); } finally { $r->clear(); } } } finally { foreach ( array( $a, $c ) as $im ) { if ( $im instanceof \Imagick ) { $im->clear(); } } unset( $a, $c ); } $u24 = static fn( int $n ): string => substr( pack( 'V', $n ), 0, 3 ); $body = 'VP8X' . pack( 'V', 10 ) . chr( 0x02 | ( $alpha ? 0x10 : 0 ) ) . "\0\0\0" . $u24( $w - 1 ) . $u24( $h - 1 ) . 'ANIM' . pack( 'V', 6 ) . "\0\0\0\0" . pack( 'v', 0 ); foreach ( $frames as $f ) { if ( $f['ms'] > 0xffffff ) { throw new \RuntimeException( 'animated_timing_held' ); } // Flags 0x02: do not blend (the area replaces the canvas, transparency included), do not dispose. $payload = $u24( $f['x'] / 2 ) . $u24( $f['y'] / 2 ) . $u24( $f['w'] - 1 ) . $u24( $f['h'] - 1 ) . $u24( $f['ms'] ) . chr( 0x02 ) . 'VP8L' . pack( 'V', strlen( $f['vp8l'] ) ) . $f['vp8l'] . ( strlen( $f['vp8l'] ) % 2 ? "\0" : '' ); $body .= 'ANMF' . pack( 'V', strlen( $payload ) ) . $payload; } $file = 'RIFF' . pack( 'V', 4 + strlen( $body ) ) . 'WEBP' . $body; self::lossless_animation( ImageInput::webp_chunks( $file, true ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. if ( Reviewed_Store::file_put_contents( $temporary, $file, LOCK_EX ) !== strlen( $file ) ) { throw new \RuntimeException( 'candidate_write_failed' ); } return array( 'fidelity' => 'lossless', 'quality_check' => array( 'method' => 'exact visible RGBA of every composed frame, decoded again', 'exact' => true, 'frames' => $input['frames'], 'pixels_checked' => $area, 'passed' => true, ), 'quality_setting' => 'explicit lossless', 'verification' => 'Exact visible RGBA of every frame verified.', 'metadata_handling' => $recipe['metadata'], ); } /** * A composed image's pixels as 8-bit RGBA bytes. * * @param \Imagick $sequence The animation, on the frame to read. * @return string The frame's RGBA bytes. */ private static function rgba( \Imagick $sequence ): string { $f = $sequence->getImage(); try { $f->setImageFormat( 'rgba' ); $f->setImageDepth( 8 ); return $f->getImageBlob(); } finally { $f->clear(); } } /** * The smallest box holding every pixel that differs between two composed images, or null when none does. * * @param string $before RGBA bytes. * @param string $after RGBA bytes of the same size. * @param int $w Width in pixels. * @return array|null x0, y0, x1, y1 (inclusive). */ private static function changed( string $before, string $after, int $w ): ?array { $x = $before ^ $after; if ( strspn( $x, "\0" ) === strlen( $x ) ) { return null; } $row = 4 * $w; $box = array( $w, PHP_INT_MAX, -1, -1 ); for ( $y = 0, $rows = (int) ( strlen( $x ) / $row ); $y < $rows; ++$y ) { $line = substr( $x, $y * $row, $row ); $first = strspn( $line, "\0" ); if ( $first === $row ) { continue; } $last = $row - 1 - strspn( strrev( $line ), "\0" ); $box[0] = min( $box[0], intdiv( $first, 4 ) ); $box[2] = max( $box[2], intdiv( $last, 4 ) ); $box[1] = min( $box[1], $y ); $box[3] = $y; } return $box; } /** * One area as a lossless WebP picture's VP8L bitstream, decoded again and compared with the area. * * @param \Imagick $r The area. * @param array $recipe The compression recipe. * @return array The VP8L bitstream, and whether it uses alpha. * @throws \RuntimeException When the picture is not exactly the area, or holds anything but the bitstream. */ private static function lossless_area( \Imagick $r, array $recipe ): array { $e = clone $r; $d = null; try { $e->setImageFormat( 'webp' ); $e->setOption( 'webp:lossless', 'true' ); $e->setOption( 'webp:exact', 'true' ); $e->setOption( 'webp:method', 'fast' === $recipe['effort'] ? '1' : '4' ); $e->stripImage(); $vp8l = null; foreach ( ImageInput::webp_chunks( $e->getImageBlob() ) as $chunk ) { if ( 'VP8L' === $chunk['type'] ) { $vp8l = $chunk['data']; } elseif ( 'VP8X' !== $chunk['type'] ) { throw new \RuntimeException( 'not_verified_lossless_webp' ); } } if ( null === $vp8l || strlen( $vp8l ) < 5 || "\x2f" !== $vp8l[0] ) { throw new \RuntimeException( 'not_verified_lossless_webp' ); } $d = new \Imagick(); $d->readImageBlob( 'RIFF' . pack( 'V', 12 + strlen( $vp8l ) + strlen( $vp8l ) % 2 ) . 'WEBPVP8L' . pack( 'V', strlen( $vp8l ) ) . $vp8l . ( strlen( $vp8l ) % 2 ? "\0" : '' ) ); if ( $d->getImageWidth() !== $r->getImageWidth() || $d->getImageHeight() !== $r->getImageHeight() || ! self::same_frame( $r, $d, $r->getImageWidth(), $r->getImageHeight() ) ) { throw new \RuntimeException( 'pixel_or_source_mismatch' ); } return array( $vp8l, (bool) ( ( unpack( 'V', substr( $vp8l, 1, 4 ) )[1] >> 28 ) & 1 ) ); } finally { $e->clear(); if ( $d instanceof \Imagick ) { $d->clear(); } } } /** * The copy holds an animation that repeats for ever (ANIM), lossless frames only (each ANMF holds one VP8L) and * nothing else beside its VP8X header. * * @param array $chunks The copy's chunks. * @throws \RuntimeException When it holds anything else. */ private static function lossless_animation( array $chunks ): void { $loop = null; $frames = 0; foreach ( $chunks as $c ) { if ( 'ANIM' === $c['type'] && 6 === strlen( $c['data'] ) ) { $loop = unpack( 'v', substr( $c['data'], 4, 2 ) )[1]; } elseif ( 'ANMF' === $c['type'] ) { $types = array(); $p = 16; $n = strlen( $c['data'] ); while ( $p + 8 <= $n ) { $types[] = substr( $c['data'], $p, 4 ); $l = unpack( 'V', substr( $c['data'], $p + 4, 4 ) )[1]; $p += 8 + $l + ( $l % 2 ); } if ( array( 'VP8L' ) !== $types || $p !== $n ) { throw new \RuntimeException( 'not_verified_lossless_webp' ); } ++$frames; } elseif ( 'VP8X' !== $c['type'] ) { throw new \RuntimeException( 'metadata_roundtrip_failed' ); } } if ( 0 !== $loop || ! $frames ) { throw new \RuntimeException( 'not_verified_lossless_webp' ); } } /** * Every pixel's alpha, and the colour of every pixel that is not fully transparent, are equal. * * @param \Imagick $a A frame. * @param \Imagick $b The frame to compare. * @param int $w The width. * @param int $h The height. * @return bool True when they are equal. */ private static function same_frame( \Imagick $a, \Imagick $b, int $w, int $h ): bool { for ( $y = 0; $y < $h; ++$y ) { $ra = $a->exportImagePixels( 0, $y, $w, 1, 'RGBA', \Imagick::PIXEL_CHAR ); $rb = $b->exportImagePixels( 0, $y, $w, 1, 'RGBA', \Imagick::PIXEL_CHAR ); if ( $ra === $rb ) { continue; } for ( $k = 0, $n = count( $ra ); $k < $n; $k += 4 ) { if ( $ra[ $k + 3 ] !== $rb[ $k + 3 ] || ( 0 !== $ra[ $k + 3 ] && ( $ra[ $k ] !== $rb[ $k ] || $ra[ $k + 1 ] !== $rb[ $k + 1 ] || $ra[ $k + 2 ] !== $rb[ $k + 2 ] ) ) ) { return false; } } } return true; } } /** * Lossless JPEG through jpegtran, where the host has it: the coefficients kept, the result decoded again and compared * exactly. */ final class JpegTransform implements EncoderAdapter { /** * The jpegtran program found, '' for none. * * @var string|null */ private static ?string $binary = null; /** * The encoder's name. * * @return string jpegtran. */ public function id(): string { return 'jpegtran'; } /** * A fingerprint of the jpegtran program, recorded with each copy. * * @return string The fingerprint, or unavailable. */ public function version(): string { $p = self::path(); return $p ? 'jpegtran-sha256:' . substr( hash_file( 'sha256', $p ), 0, 16 ) : 'unavailable'; } /** * The format the encoder writes. * * @return string jpeg. */ public function format(): string { return 'jpeg'; } /** * The jpegtran program's path: a fixed binary outside the uploads folder that no one but its owner may change * (/usr/bin/jpegtran or /usr/local/bin/jpegtran, or the constant AFSD_IM_JPEGTRAN). * * @return string The path, or '' when there is none. */ public static function path(): string { if ( null !== self::$binary ) { return self::$binary; } self::$binary = ''; if ( ! function_exists( 'proc_open' ) || DIRECTORY_SEPARATOR !== '/' ) { return ''; } $paths = defined( 'AFSD_IM_JPEGTRAN' ) && is_string( AFSD_IM_JPEGTRAN ) ? array( AFSD_IM_JPEGTRAN ) : array( '/usr/bin/jpegtran', '/usr/local/bin/jpegtran' ); foreach ( $paths as $p ) { if ( ! str_starts_with( $p, '/' ) || preg_match( '/[\x00-\x1f]/', $p ) || is_link( $p ) || realpath( $p ) !== $p || ! is_file( $p ) || ! is_readable( $p ) || ! is_executable( $p ) || ( fileperms( $p ) & 0022 ) ) { continue; } $uploads = realpath( wp_get_upload_dir()['basedir'] ); if ( $uploads && str_starts_with( $p, $uploads . '/' ) ) { continue; } $parent = dirname( $p ); $safe = true; while ( $parent !== '/' && $parent !== '.' ) { if ( is_link( $parent ) || ( fileperms( $parent ) & 0022 ) ) { $safe = false; break; } $parent = dirname( $parent ); } if ( $safe ) { self::$binary = $p; break; } } return self::$binary; } /** * No shell interpolation; stdin and output are bounded. Timeout applies only to this subprocess. * * @param string $input The JPEG's bytes. * @param string $output Where jpegtran writes the result. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function run( string $input, string $output ): void { $binary = self::path(); if ( ! $binary ) { throw new \RuntimeException( 'jpegtran_unavailable' ); } // phpcs:ignore Generic.PHP.ForbiddenFunctions.Found -- Fixed argv array and bypass_shell invoke only a validated non-writable local jpegtran binary; input, output and runtime are bounded. $pipes = array(); // phpcs:ignore Generic.PHP.ForbiddenFunctions.Found -- Fixed argv array and bypass_shell invoke only a validated non-writable local jpegtran binary; input, output and runtime are bounded. $proc = proc_open( array( $binary, '-copy', 'all', '-optimize', '-progressive', '-maxmemory', '64m', '-outfile', $output ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ), ), $pipes, dirname( $output ), array( 'LC_ALL' => 'C', 'PATH' => '/usr/bin:/bin', ), array( 'bypass_shell' => true ) ); if ( ! is_resource( $proc ) ) { throw new \RuntimeException( 'jpegtran_unavailable' ); } $offset = 0; $log = ''; $until = microtime( true ) + 15; $status = null; foreach ( $pipes as $pipe ) { stream_set_blocking( $pipe, false ); } try { do { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. if ( isset( $pipes[0] ) ) { $w = Reviewed_Store::fwrite( $pipes[0], substr( $input, $offset, 65536 ) ); if ( false === $w ) { throw new \RuntimeException( 'codec_warning' ); } $offset += $w; if ( $offset >= strlen( $input ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. fclose( $pipes[0] ); unset( $pipes[0] ); } } foreach ( array( 1, 2 ) as $n ) { $log .= stream_get_contents( $pipes[ $n ] ); } clearstatcache( true, $output ); if ( strlen( $log ) > 65536 || ( is_file( $output ) && filesize( $output ) > Settings::get()['max_bytes'] ) ) { throw new \RuntimeException( 'encoder_output_limit' ); } $status = proc_get_status( $proc ); if ( ! $status['running'] ) { break; } if ( microtime( true ) >= $until ) { throw new \RuntimeException( 'encoder_timeout' ); } usleep( 10000 ); } while ( true ); if ( $status['exitcode'] !== 0 || $offset !== strlen( $input ) ) { throw new \RuntimeException( 'codec_warning' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. } finally { $st = proc_get_status( $proc ); if ( $st['running'] ) { proc_terminate( $proc, 9 ); } foreach ( $pipes as $pipe ) { if ( is_resource( $pipe ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. fclose( $pipe ); } } proc_close( $proc ); } } /** * Optimize a JPEG losslessly with jpegtran and check its metadata is unchanged and its decoded pixels are exact. * * @param array $input The JPEG's input record. * @param string $temporary Where to write the candidate. * @param array $recipe The compression recipe. * @param string $fidelity Always lossless here. * @return array The candidate's measurements and its check. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public function encode( array $input, string $temporary, array $recipe, string $fidelity ): array { if ( 'image/jpeg' !== $input['mime'] ) { throw new \RuntimeException( 'unsupported_format' ); } $data = ImageInput::sanitized( $input, $recipe ); self::run( $data, $temporary ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Host image encoders require bounded local binary files/streams; input and output limits and pixel verification are enforced by the encoder pipeline. $out = file_get_contents( $temporary ); if ( ! is_string( $out ) || ImageInput::jpeg_metadata( $data ) !== ImageInput::jpeg_metadata( $out ) ) { throw new \RuntimeException( 'metadata_roundtrip_failed' ); } // GD ignores EXIF orientation, intentionally comparing encoded raster coordinates without rotating. $backend = function_exists( 'imagecreatefromstring' ) ? 'gd' : ( class_exists( '\Imagick' ) ? 'imagick' : '' ); if ( ! $backend ) { throw new \RuntimeException( 'jpeg_verifier_unavailable' ); } $a = RasterEncoder::decode( $data, $backend ); $b = null; try { $b = RasterEncoder::decode( $out, $backend ); $check = PixelCheck::compare( $a, $b, $backend, 'lossless' ); } finally { if ( 'imagick' === $backend ) { $a->clear(); if ( $b ) { $b->clear(); } }unset( $a, $b ); } if ( ! $check['passed'] ) { throw new \RuntimeException( 'pixel_or_source_mismatch' ); } return array( 'fidelity' => 'lossless', 'verification' => 'JPEG coefficient optimization; exact decoded raster and preserved metadata verified.', 'quality_check' => $check, 'metadata_handling' => $recipe['metadata'], ); } }