Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Path
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
7 / 7
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 path
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateFile
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 validatePath
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isWithinRoot
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Boiler;
6
7use Celemas\Boiler\Exception\LookupException;
8
9/** @internal */
10final class Path
11{
12    private bool $isValid = false;
13    private string $path = '';
14    private string $error = '';
15
16    /**
17     * @param non-empty-string $dir
18     * @param non-empty-string $file
19     */
20    public function __construct(
21        private string $dir,
22        string $file,
23    ) {
24        if (strlen(trim($dir)) === 0) {
25            $this->error = 'Template directory must not be an empty string';
26
27            return;
28        }
29
30        $dir = realpath($this->dir);
31
32        if ($dir === false) {
33            $this->error = "Template directory not found: '{$this->dir}'";
34
35            return;
36        }
37
38        assert($dir !== '', 'Resolved template directory path must not be empty');
39
40        $this->dir = $dir;
41        $this->validateFile($dir, $file);
42    }
43
44    /** @return non-empty-string */
45    public function path(): string
46    {
47        if (!$this->isValid || $this->path === '') {
48            throw new LookupException("Error while accessing path of invalid template: `{$this->path}`");
49        }
50
51        return $this->path;
52    }
53
54    public function error(): string
55    {
56        return $this->error;
57    }
58
59    public function isValid(): bool
60    {
61        return $this->isValid;
62    }
63
64    private function validateFile(string $dir, string $file): void
65    {
66        $fullPath = $dir . DIRECTORY_SEPARATOR . $file;
67
68        if (str_ends_with($fullPath, '.php')) {
69            $this->validatePath($fullPath);
70        } else {
71            $this->validatePath("{$fullPath}.php");
72
73            if (!$this->isValid) {
74                $this->validatePath($fullPath);
75            }
76        }
77
78        if ($this->isValid && !$this->isWithinRoot($this->path)) {
79            $this->error = "Template resides outside of root directory ({$this->dir}): {$this->path}";
80            $this->isValid = false;
81        }
82    }
83
84    /** @param non-empty-string $path */
85    private function validatePath(string $path): void
86    {
87        $realpath = realpath($path);
88
89        if ($realpath === false || strlen($realpath) === 0) {
90            $this->error = "Template not found: {$path}";
91
92            return;
93        }
94
95        assert($realpath !== '', 'Resolved template file path must not be empty');
96
97        $this->isValid = true;
98        $this->path = $realpath;
99        $this->error = '';
100    }
101
102    private function isWithinRoot(string $path): bool
103    {
104        $root = rtrim($this->dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
105
106        if (DIRECTORY_SEPARATOR === '\\') {
107            // Windows-only branch
108            // @codeCoverageIgnoreStart
109
110            return strncasecmp($path, $root, strlen($root)) === 0;
111
112            // @codeCoverageIgnoreEnd
113        }
114
115        return str_starts_with($path, $root);
116    }
117}