Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.68% covered (warning)
88.68%
47 / 53
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResourceLoader
88.68% covered (warning)
88.68%
47 / 53
25.00% covered (danger)
25.00%
1 / 4
28.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 resolveLocalPath
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
11.35
 decodeDataUrl
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
10
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Filesystem;
6
7/**
8 * Centralised entry point for fetching the bytes of every URL-shaped
9 * resource an html-to-pdf / svg-to-pdf render walks into — fonts via
10 * `@font-face src: url(...)`, images via `<img src>` /
11 * `background-image: url(...)`, stylesheets via `<link rel="stylesheet">`
12 * / `@import`. Replaces the per-call-site data-URL + baseDir + realpath
13 * boilerplate that had drifted into four near-identical resolvers.
14 *
15 * Phase-1 supported sources:
16 *   - `data:<mime>[;base64],<payload>` URLs (MIME-validated when the
17 *     caller supplies an allowlist)
18 *   - Relative paths joined with the configured `$baseDir`
19 *   - Absolute filesystem paths resolved under the same `$baseDir`
20 *
21 * All security gates documented in `docs/plans/html-and-svg.md`
22 * apply uniformly: `realpath` escape rejection (no `..` walks out of
23 * `baseDir`), stream-wrapper rejection (`php://`, `phar://`, etc.) via
24 * the underlying `LocalFilesystem::assertLocalPath`, and explicit URL
25 * scheme rejection (`http://`, `https://`, `ftp://`, ...) — remote
26 * fetching lands in Phase 2 behind the same surface, with SSRF gates
27 * added then.
28 *
29 * `data:` URLs are accepted unconditionally; the caller's allowlist
30 * (`$allowedMimes`) is a *MIME match* check, not a security gate —
31 * binary payloads still get the same treatment as the on-disk path.
32 */
33final readonly class ResourceLoader
34{
35    /**
36     * @param ?string $baseDir Root used to resolve relative URLs.
37     * @param ?string $sandboxRoot Broader sandbox boundary that the
38     *   resolved path must remain under. Defaults to `$baseDir` —
39     *   the historical behaviour, where a relative URL can't escape
40     *   the resolution root. When set wider (e.g. the entire WPT
41     *   test corpus while `$baseDir` is the individual test's dir),
42     *   `../sibling-dir/x.png` resolves correctly.
43     */
44    public function __construct(
45        public ?string $baseDir = null,
46        public ?string $sandboxRoot = null,
47    ) {}
48
49    /**
50     * Resolve `$url` to its raw bytes. Returns null when:
51     *   - the URL doesn't match a Phase-1 supported scheme;
52     *   - a relative path doesn't resolve under `$baseDir`;
53     *   - `$allowedMimes` is non-empty and the resource's declared MIME
54     *     isn't in the list (only enforced for `data:` URLs — disk paths
55     *     have no transport-level MIME);
56     *   - the underlying read throws (stream-wrapper, permissions, etc.).
57     *
58     * @param list<string>|null $allowedMimes Lower-case MIME types. When
59     *     null, any data: MIME is accepted. When set, only those MIMEs
60     *     pass through.
61     */
62    public function load(string $url, ?array $allowedMimes = null): ?string
63    {
64        if ($url === '') {
65            return null;
66        }
67        if (str_starts_with($url, 'data:')) {
68            return $this->decodeDataUrl($url, $allowedMimes);
69        }
70        $resolved = $this->resolveLocalPath($url);
71        if ($resolved === null) {
72            return null;
73        }
74        try {
75            return LocalFilesystem::readFile($resolved);
76        } catch (\Throwable) {
77            return null;
78        }
79    }
80
81    /**
82     * Resolve `$url` to a real filesystem path, or null when it can't
83     * be confirmed safe under `$baseDir`. Useful when the caller needs
84     * the path itself rather than the bytes — e.g. `PdfWriter::addImage`
85     * accepts a path, not a buffer.
86     *
87     * Rejects non-`file://` URL schemes, paths that escape `$baseDir`
88     * via `realpath`, and stream-wrapper paths via
89     * {@see LocalFilesystem::assertLocalPath}.
90     */
91    public function resolveLocalPath(string $url): ?string
92    {
93        if ($this->baseDir === null) {
94            return null;
95        }
96        if (preg_match('~^[a-zA-Z][a-zA-Z0-9+.-]*://~', $url) === 1) {
97            return null;
98        }
99        // URLs starting with `/` follow the "document root" convention:
100        // when a sandboxRoot is configured separately from baseDir, the
101        // slash anchors to the sandbox (this matches the WPT corpus
102        // layout where `/fonts/math/x.woff` refers to a file under the
103        // corpus root, not a real absolute filesystem path). Without a
104        // distinct sandbox, fall back to treating the leading `/` as a
105        // real filesystem path - the legacy single-baseDir behaviour.
106        if (str_starts_with($url, '/')) {
107            $candidate = $this->sandboxRoot !== null
108                && $this->sandboxRoot !== $this->baseDir
109                ? rtrim($this->sandboxRoot, DIRECTORY_SEPARATOR) . $url
110                : $url;
111        } else {
112            $candidate = $this->baseDir . DIRECTORY_SEPARATOR . $url;
113        }
114        $resolved = realpath($candidate);
115        $sandbox = realpath($this->sandboxRoot ?? $this->baseDir);
116        if ($resolved === false || $sandbox === false) {
117            return null;
118        }
119        if (!str_starts_with($resolved, $sandbox . DIRECTORY_SEPARATOR)
120            && $resolved !== $sandbox
121        ) {
122            return null;
123        }
124        try {
125            LocalFilesystem::assertLocalPath($resolved);
126        } catch (\Throwable) {
127            return null;
128        }
129        return $resolved;
130    }
131
132    /**
133     * Decode a `data:<mime>[;base64],<payload>` URL into raw bytes.
134     * Honours both base64 and URL-encoded (rfc2397) payloads. When
135     * `$allowedMimes` is non-empty, the declared MIME must match
136     * exactly (case-insensitive); a payload claiming `image/png`
137     * passes for an `['image/png']` allowlist but not for `['image/jpeg']`.
138     *
139     * @param list<string>|null $allowedMimes
140     */
141    private function decodeDataUrl(string $url, ?array $allowedMimes): ?string
142    {
143        // data:[<mime>][;params][;base64],<payload>
144        $commaPos = strpos($url, ',');
145        if ($commaPos === false) {
146            return null;
147        }
148        $header = substr($url, 5, $commaPos - 5); // strip leading `data:`
149        $payload = substr($url, $commaPos + 1);
150        $isBase64 = false;
151        $mime = '';
152        if ($header !== '') {
153            $parts = explode(';', $header);
154            $mime = strtolower(trim($parts[0]));
155            for ($i = 1; $i < count($parts); $i++) {
156                if (strtolower(trim($parts[$i])) === 'base64') {
157                    $isBase64 = true;
158                }
159            }
160        }
161        if ($allowedMimes !== null && $allowedMimes !== []) {
162            $allowed = array_map('strtolower', $allowedMimes);
163            if (!in_array($mime, $allowed, true)) {
164                return null;
165            }
166        }
167        if ($isBase64) {
168            $decoded = base64_decode($payload, true);
169            return $decoded === false ? null : $decoded;
170        }
171        return urldecode($payload);
172    }
173}