Skip to content

Commit 31d2689

Browse files
committed
refactor(filename): extract classes with single responsibilities
- Create namespace KaririCode\Sanitizer\Processor\Security\Filename - Split FilenameSanitizer into specific classes: * ExtensionHandler: handling of extensions and validations * FilenameParser: parsing and splitting of filenames * BasenameSanitizer: sanitization of the base name * Configuration: configuration management * FilenameSanitizer: orchestration of the process
1 parent ff56ead commit 31d2689

14 files changed

+482
-96
lines changed

src/Processor/Domain/JsonSanitizer.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@
55
namespace KaririCode\Sanitizer\Processor\Domain;
66

77
use KaririCode\Sanitizer\Processor\AbstractSanitizerProcessor;
8+
use KaririCode\Sanitizer\Trait\WhitespaceSanitizerTrait;
89

910
class JsonSanitizer extends AbstractSanitizerProcessor
1011
{
12+
use WhitespaceSanitizerTrait;
13+
1114
public function process(mixed $input): string
1215
{
1316
$input = $this->guardAgainstNonString($input);
17+
$input = $this->trimWhitespace($input);
18+
1419
$decoded = json_decode($input, true);
1520
if (JSON_ERROR_NONE !== json_last_error()) {
1621
throw new \InvalidArgumentException('Invalid JSON input');

src/Processor/Domain/MarkdownSanitizer.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,27 @@
55
namespace KaririCode\Sanitizer\Processor\Domain;
66

77
use KaririCode\Sanitizer\Processor\AbstractSanitizerProcessor;
8+
use KaririCode\Sanitizer\Trait\CharacterReplacementTrait;
9+
use KaririCode\Sanitizer\Trait\WhitespaceSanitizerTrait;
810

911
class MarkdownSanitizer extends AbstractSanitizerProcessor
1012
{
13+
use WhitespaceSanitizerTrait;
14+
use CharacterReplacementTrait;
15+
1116
public function process(mixed $input): string
1217
{
1318
$input = $this->guardAgainstNonString($input);
19+
$input = $this->trimWhitespace($input);
20+
1421
// Remove HTML tags, keeping Markdown intact
1522
$input = strip_tags($input);
23+
1624
// Escape special Markdown characters
17-
$input = preg_replace('/([*_`#])/', '\\\\$1', $input);
25+
return $this->replaceMultipleCharacters(
26+
$input,
27+
['*' => '\\*', '_' => '\\_', '`' => '\\`', '#' => '\\#']
28+
);
1829

1930
return $input;
2031
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KaririCode\Sanitizer\Processor\Security\Filename;
6+
7+
final class BasenameSanitizer
8+
{
9+
public function __construct(
10+
private readonly string $replacement = '_',
11+
private readonly bool $toLowerCase = false,
12+
private readonly int $maxLength = 255
13+
) {
14+
}
15+
16+
public function sanitize(string $basename, bool $preserveDots = true): string
17+
{
18+
if ($preserveDots) {
19+
return $this->sanitizePreservingDots($basename);
20+
}
21+
22+
return $this->sanitizeWithoutDots($basename);
23+
}
24+
25+
private function sanitizePreservingDots(string $basename): string
26+
{
27+
$parts = explode('.', $basename);
28+
$sanitizedParts = array_map(
29+
fn (string $part) => $this->sanitizePart($part),
30+
$parts
31+
);
32+
33+
$result = implode('.', $sanitizedParts);
34+
35+
return $this->finalizeBasename($result);
36+
}
37+
38+
private function sanitizeWithoutDots(string $basename): string
39+
{
40+
$sanitized = preg_replace('/[^\p{L}\p{N}_\-\s]/u', $this->replacement, $basename);
41+
$sanitized = str_replace([' ', '.', '-'], $this->replacement, $sanitized);
42+
$sanitized = $this->normalizeReplacement($sanitized);
43+
44+
return $this->finalizeBasename($sanitized);
45+
}
46+
47+
private function sanitizePart(string $part): string
48+
{
49+
$sanitized = preg_replace('/[^\p{L}\p{N}_\-\s]/u', $this->replacement, $part);
50+
$sanitized = str_replace([' ', '-'], $this->replacement, $sanitized);
51+
52+
return $this->normalizeReplacement($sanitized);
53+
}
54+
55+
private function normalizeReplacement(string $input): string
56+
{
57+
$normalized = preg_replace(
58+
'/' . preg_quote($this->replacement, '/') . '{2,}/',
59+
$this->replacement,
60+
$input
61+
);
62+
63+
return trim($normalized, $this->replacement);
64+
}
65+
66+
private function finalizeBasename(string $basename): string
67+
{
68+
if ($this->toLowerCase) {
69+
$basename = strtolower($basename);
70+
}
71+
72+
return $this->truncateBasename($basename);
73+
}
74+
75+
private function truncateBasename(string $basename): string
76+
{
77+
if (strlen($basename) <= $this->maxLength) {
78+
return $basename;
79+
}
80+
81+
if (false !== ($lastSpace = strrpos(substr($basename, 0, $this->maxLength), ' '))) {
82+
return substr($basename, 0, $lastSpace);
83+
}
84+
85+
return substr($basename, 0, $this->maxLength);
86+
}
87+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KaririCode\Sanitizer\Processor\Security\Filename;
6+
7+
final class Configuration
8+
{
9+
private string $replacement = '_';
10+
private bool $preserveExtension = true;
11+
private int $maxLength = 255;
12+
private bool $toLowerCase = false;
13+
private array $allowedExtensions = [];
14+
private bool $blockDangerousExtensions = true;
15+
16+
public function configure(array $options): void
17+
{
18+
$this->configureBasicOptions($options);
19+
$this->configureExtensionOptions($options);
20+
$this->configureSecurityOptions($options);
21+
}
22+
23+
private function configureBasicOptions(array $options): void
24+
{
25+
if (isset($options['replacement']) && $this->isValidReplacement($options['replacement'])) {
26+
$this->replacement = $options['replacement'];
27+
}
28+
29+
if (isset($options['preserveExtension'])) {
30+
$this->preserveExtension = (bool) $options['preserveExtension'];
31+
}
32+
33+
if (isset($options['maxLength']) && $options['maxLength'] > 0) {
34+
$this->maxLength = (int) $options['maxLength'];
35+
}
36+
37+
if (isset($options['toLowerCase'])) {
38+
$this->toLowerCase = (bool) $options['toLowerCase'];
39+
}
40+
}
41+
42+
private function configureExtensionOptions(array $options): void
43+
{
44+
if (isset($options['allowedExtensions']) && is_array($options['allowedExtensions'])) {
45+
$this->allowedExtensions = array_map('strtolower', $options['allowedExtensions']);
46+
}
47+
}
48+
49+
private function configureSecurityOptions(array $options): void
50+
{
51+
if (isset($options['blockDangerousExtensions'])) {
52+
$this->blockDangerousExtensions = (bool) $options['blockDangerousExtensions'];
53+
}
54+
}
55+
56+
private function isValidReplacement(string $replacement): bool
57+
{
58+
return 1 === strlen($replacement) && 1 === preg_match('/^[\w\-]$/', $replacement);
59+
}
60+
61+
public function getReplacement(): string
62+
{
63+
return $this->replacement;
64+
}
65+
66+
public function isPreserveExtension(): bool
67+
{
68+
return $this->preserveExtension;
69+
}
70+
71+
public function getMaxLength(): int
72+
{
73+
return $this->maxLength;
74+
}
75+
76+
public function isToLowerCase(): bool
77+
{
78+
return $this->toLowerCase;
79+
}
80+
81+
public function getAllowedExtensions(): array
82+
{
83+
return $this->allowedExtensions;
84+
}
85+
86+
public function isBlockDangerousExtensions(): bool
87+
{
88+
return $this->blockDangerousExtensions;
89+
}
90+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KaririCode\Sanitizer\Processor\Security\Filename;
6+
7+
final class ExtensionHandler
8+
{
9+
private const DANGEROUS_EXTENSIONS = [
10+
'php', 'phtml', 'phar', 'php3', 'php4', 'php5', 'php7', 'pht',
11+
'exe', 'bat', 'cmd', 'sh', 'cgi', 'pl', 'py',
12+
'asp', 'aspx', 'jsp', 'jspx',
13+
];
14+
private const COMPOUND_EXTENSIONS = [
15+
'.tar.gz',
16+
'.tar.bz2',
17+
'.tar.xz',
18+
];
19+
20+
private bool $blockDangerousExtensions;
21+
private array $allowedExtensions;
22+
23+
public function __construct(bool $blockDangerousExtensions = true, array $allowedExtensions = [])
24+
{
25+
$this->blockDangerousExtensions = $blockDangerousExtensions;
26+
$this->allowedExtensions = array_map('strtolower', $allowedExtensions);
27+
}
28+
29+
public function sanitizeExtension(string $extension): string
30+
{
31+
if ('' === $extension) {
32+
return '';
33+
}
34+
35+
$extension = strtolower($extension);
36+
$extension = ltrim($extension, '.');
37+
38+
$parts = explode('.', $extension);
39+
$lastPart = end($parts);
40+
41+
if ($this->isDangerousExtension($lastPart)) {
42+
return '';
43+
}
44+
45+
if ($this->hasAllowedExtensionsRestriction() && !$this->isAllowedExtension($lastPart)) {
46+
return '';
47+
}
48+
49+
return '.' . $extension;
50+
}
51+
52+
public function isCompoundExtension(string $filename): bool
53+
{
54+
foreach (self::COMPOUND_EXTENSIONS as $ext) {
55+
if (str_ends_with(strtolower($filename), $ext)) {
56+
return true;
57+
}
58+
}
59+
60+
return false;
61+
}
62+
63+
private function isDangerousExtension(string $extension): bool
64+
{
65+
if (!$this->blockDangerousExtensions) {
66+
return false;
67+
}
68+
69+
return in_array(strtolower($extension), self::DANGEROUS_EXTENSIONS, true);
70+
}
71+
72+
private function hasAllowedExtensionsRestriction(): bool
73+
{
74+
return !empty($this->allowedExtensions);
75+
}
76+
77+
private function isAllowedExtension(string $extension): bool
78+
{
79+
return in_array($extension, $this->allowedExtensions, true);
80+
}
81+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KaririCode\Sanitizer\Processor\Security\Filename;
6+
7+
final class FilenameParser
8+
{
9+
public function __construct(
10+
private readonly ExtensionHandler $extensionHandler
11+
) {
12+
}
13+
14+
public function splitFilename(string $filename, bool $preserveExtension): array
15+
{
16+
if (!$preserveExtension) {
17+
return [$filename, ''];
18+
}
19+
20+
$lastDotPosition = strrpos($filename, '.');
21+
if (false === $lastDotPosition) {
22+
return [$filename, ''];
23+
}
24+
25+
$basename = substr($filename, 0, $lastDotPosition);
26+
$extension = substr($filename, $lastDotPosition);
27+
28+
if ($this->shouldHandleCompoundExtension($filename, $basename)) {
29+
$previousDotPosition = strrpos($basename, '.');
30+
$basename = substr($filename, 0, $previousDotPosition);
31+
$extension = substr($filename, $previousDotPosition);
32+
}
33+
34+
return [$basename, $extension];
35+
}
36+
37+
private function shouldHandleCompoundExtension(string $filename, string $basename): bool
38+
{
39+
$previousDotPosition = strrpos($basename, '.');
40+
41+
return false !== $previousDotPosition && $this->extensionHandler->isCompoundExtension($filename);
42+
}
43+
}

0 commit comments

Comments
 (0)