Lines 95.45% 21 / 22
Functions and Methods 80.00% 4 / 5
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
UrlRedactor 95.45% 21 / 22 80.00% 4 / 5 14 0.00% 0 / 1
 __construct 0.00% 0 / 1 0.00% 0 / 1 2
 redactCredentials 100.00% 6 / 6 100.00% 1 / 1 4
 looksLikeUrl 100.00% 1 / 1 100.00% 1 / 1 1
 maskUserinfo 100.00% 5 / 5 100.00% 1 / 1 3
 redactCredentialQueryParameters 100.00% 9 / 9 100.00% 1 / 1 5
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Support\Url;
6
7use Stewart\Support\Secret\SecretName;
8
9/** @internal */
10final class UrlRedactor
11{
12    private const string MASK = '***';
13
14    private const string URL_PARTS = '#^([a-z][a-z0-9+.-]*)://([^/?\#]*)([^?\#]*)(?:\?([^\#]*))?(?:\#(.*))?$#is';
15
16    private function __construct() {}
17
18    public static function redactCredentials(string $value): string
19    {
20        // parse_url() rejects host-less URLs such as unix:///run/valkey.sock, so the parts are split here.
21        if (preg_match(self::URL_PARTS, $value, $parts, \PREG_UNMATCHED_AS_NULL) !== 1) {
22            return $value;
23        }
24
25        [, $scheme, $authority, $path, $query, $fragment] = array_pad($parts, 6, null);
26
27        return $scheme . '://' . self::maskUserinfo((string) $authority) . $path
28            . ($query === null ? '' : '?' . self::redactCredentialQueryParameters($query))
29            . ($fragment === null ? '' : '#' . self::MASK);
30    }
31
32    public static function looksLikeUrl(string $value): bool
33    {
34        return preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) === 1;
35    }
36
37    private static function maskUserinfo(string $authority): string
38    {
39        $at = strrpos($authority, '@');
40
41        if ($at === false) {
42            return $authority;
43        }
44
45        $userinfo = str_contains(substr($authority, 0, $at), ':') ? self::MASK . ':' . self::MASK : self::MASK;
46
47        return $userinfo . '@' . substr($authority, $at + 1);
48    }
49
50    private static function redactCredentialQueryParameters(string $query): string
51    {
52        $pairs = [];
53
54        foreach (explode('&', $query) as $pair) {
55            if ($pair === '') {
56                continue;
57            }
58
59            $split = explode('=', $pair, 2);
60            $name = $split[0];
61
62            $pairs[] = \count($split) === 1 || !SecretName::looksLikeCredentialParameter($name)
63                ? $pair
64                : $name . '=' . self::MASK;
65        }
66
67        return implode('&', $pairs);
68    }
69}