solid_pod_rs/multitenant.rs
1//! Pod resolution from request host (Sprint 7 §6.3, ADR-057).
2//!
3//! JSS parity: `src/utils/url.js::urlToPathWithPod` and the
4//! `subdomainsEnabled && podName` branch in `getPathFromRequest`.
5//! We lift the policy ("which pod owns this request?") out of the
6//! URL-to-filesystem mapper so that call sites (LDP, WAC, quota) can
7//! consult it uniformly without each re-parsing the Host header.
8//!
9//! # Model
10//!
11//! - [`PathResolver`] — default single-tenant behaviour. The URL path
12//! is the storage path verbatim and `pod` is `None`.
13//! - [`SubdomainResolver`] — `<pod>.<base_domain>` maps the first label
14//! to a pod identifier; bare `<base_domain>` returns the root pod
15//! (`pod: None`). Anything else (unknown subdomain tree) falls back
16//! to path-based semantics.
17//!
18//! # Security
19//!
20//! Pod labels are scrubbed of `..` sequences with the same **double-pass**
21//! algorithm JSS uses in `urlToPathWithPod` (`..` is replaced until the
22//! string stops changing, defeating the `....//` bypass). Any resulting
23//! empty or path-containing label is rejected by falling back to path
24//! mode with `pod: None`.
25
26/// Result of resolving a request to a pod + storage path.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ResolvedPath {
29 /// Pod identifier, or `None` for single-tenant / root pod.
30 pub pod: Option<String>,
31 /// Storage path relative to the pod root (or global root when
32 /// `pod` is `None`). Verbatim from the URL — no percent-decoding
33 /// here; callers handle encoding via their storage trait.
34 pub storage_path: String,
35}
36
37/// Policy that maps `(host, url_path)` onto a [`ResolvedPath`].
38pub trait PodResolver: Send + Sync {
39 fn resolve(&self, host: &str, url_path: &str) -> ResolvedPath;
40}
41
42// ---------------------------------------------------------------------------
43// PathResolver — single-tenant pass-through.
44// ---------------------------------------------------------------------------
45
46/// Single-tenant / path-based resolver. Equivalent to JSS's
47/// `subdomainsEnabled=false` mode: the URL path *is* the storage path
48/// and there is no notion of a per-host pod.
49pub struct PathResolver;
50
51impl PodResolver for PathResolver {
52 fn resolve(&self, _host: &str, url_path: &str) -> ResolvedPath {
53 ResolvedPath {
54 pod: None,
55 storage_path: url_path.to_string(),
56 }
57 }
58}
59
60// ---------------------------------------------------------------------------
61// SubdomainResolver — `<pod>.<base_domain>` → pod = first label.
62// ---------------------------------------------------------------------------
63
64/// Subdomain-based resolver. Matches hosts of the form
65/// `<pod>.<base_domain>` and yields `pod = Some(<pod>)`. The bare
66/// base domain yields `pod = None` (root pod). Hosts outside the base
67/// domain tree fall back to path-based semantics.
68pub struct SubdomainResolver {
69 /// Authoritative base domain (e.g. `"example.org"`). Port is
70 /// ignored at match time; see the private `strip_port` helper.
71 pub base_domain: String,
72}
73
74impl PodResolver for SubdomainResolver {
75 fn resolve(&self, host: &str, url_path: &str) -> ResolvedPath {
76 let host_no_port = strip_port(host);
77 let base = self.base_domain.trim();
78
79 // Bare base domain → root pod. `eq_ignore_ascii_case` on the
80 // borrowed slices keeps the previous case-insensitive match
81 // without a per-request `to_ascii_lowercase` allocation.
82 if host_no_port.eq_ignore_ascii_case(base) {
83 return ResolvedPath {
84 pod: None,
85 storage_path: url_path.to_string(),
86 };
87 }
88
89 // `<pod>.<base_domain>` — peel the suffix. Require the
90 // separator dot so `fooexample.org` doesn't match `example.org`.
91 // The tail comparison is byte-wise and ASCII-case-insensitive,
92 // so the per-request host-wide `to_ascii_lowercase` and the
93 // `format!`ed suffix string are gone; the label alone is
94 // lowercased below, and only on the matched-suffix path.
95 let host_b = host_no_port.as_bytes();
96 let base_b = base.as_bytes();
97 if host_b.len() > base_b.len()
98 && host_b[host_b.len() - base_b.len()..].eq_ignore_ascii_case(base_b)
99 && host_b[host_b.len() - base_b.len() - 1] == b'.'
100 {
101 let label = &host_no_port[..host_no_port.len() - base_b.len() - 1];
102
103 // Sprint 11 (row 125/162, JSS PR #307 commit 6d43e66):
104 // "subdomain mode: don't rewrite file-like paths as pod
105 // subdomains". If the leftmost label looks like a filename
106 // (ends in a common web asset extension), pass it through
107 // to the base apex instead of treating it as a pod name.
108 // This prevents `favicon.ico.pods.example.com` requests
109 // being rerouted to a non-existent pod named `favicon.ico`.
110 // `is_file_like_label` lowercases internally, so passing
111 // the original-case label is equivalent.
112 if is_file_like_label(label) {
113 return ResolvedPath {
114 pod: None,
115 storage_path: url_path.to_string(),
116 };
117 }
118
119 // Scrub `..` *first* (JSS double-pass) so that a label
120 // like `al..ice` normalises to `alice` before we decide
121 // whether it is a multi-label subdomain. The label is
122 // lowercased here — pod names come out exactly as when the
123 // whole host was lowercased up front.
124 let safe = scrub_dotdot(&label.to_ascii_lowercase());
125 // Only accept single-label subdomains after scrubbing;
126 // multi-level subdomains (`a.b.example.org`) fall back to
127 // path mode so we don't accidentally promote DNS labels to
128 // pod names. Reject labels containing `/` or any residual
129 // `..` that somehow survived scrubbing.
130 if !safe.is_empty()
131 && !safe.contains('.')
132 && !safe.contains('/')
133 && !safe.contains("..")
134 {
135 return ResolvedPath {
136 pod: Some(safe),
137 storage_path: url_path.to_string(),
138 };
139 }
140 }
141
142 // Fallback policy: unknown host → path-based semantics. This
143 // mirrors JSS's `subdomainsEnabled && podName` guard: when no
144 // pod can be derived the server still serves from the shared
145 // root instead of rejecting.
146 ResolvedPath {
147 pod: None,
148 storage_path: url_path.to_string(),
149 }
150 }
151}
152
153/// Sprint 11 (row 125/162, JSS PR #307 `6d43e66`): return `true` when
154/// the hostname label looks like a filename that should be served from
155/// the base apex rather than promoted to a pod subdomain.
156///
157/// The heuristic is intentionally conservative: only a small list of
158/// common web-asset extensions matches. DNS labels are case-insensitive,
159/// so matching is case-insensitive too.
160///
161/// Matching extensions (case-insensitive):
162/// `.ttl`, `.html`, `.ico`, `.svg`, `.json`, `.jsonld`, `.png`, `.jpg`,
163/// `.jpeg`, `.gif`, `.css`, `.js`, `.woff`, `.woff2`, `.txt`.
164pub fn is_file_like_label(label: &str) -> bool {
165 // A DNS label with no dot cannot contain an extension, so cannot
166 // match. Normalise to lowercase once for the scan.
167 let lower = label.to_ascii_lowercase();
168 if !lower.contains('.') {
169 return false;
170 }
171
172 // Known web-asset extensions that JSS routes to static-serve rather
173 // than pod-rewrite.
174 const FILE_EXTENSIONS: &[&str] = &[
175 ".ttl", ".html", ".ico", ".svg", ".json", ".jsonld", ".png", ".jpg", ".jpeg", ".gif",
176 ".css", ".js", ".woff", ".woff2", ".txt",
177 ];
178
179 FILE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
180}
181
182// ---------------------------------------------------------------------------
183// Helpers
184// ---------------------------------------------------------------------------
185
186/// Strip an optional `:<port>` suffix. IPv6 literals (which include
187/// colons) are not currently supported in subdomain mode — operators
188/// running IPv6-native setups should prefer [`PathResolver`].
189fn strip_port(host: &str) -> &str {
190 match host.rfind(':') {
191 Some(i) => &host[..i],
192 None => host,
193 }
194}
195
196/// Double-pass `..` scrub (JSS parity: `urlToPathWithPod` lines 62-66
197/// and 70-74). Repeats until the string stops shrinking, defeating the
198/// `....//` bypass.
199fn scrub_dotdot(s: &str) -> String {
200 // Fast path: a label with no `..` needs no replace pass — return
201 // the single owned copy directly, skipping the scan-and-rebuild
202 // allocation `String::replace` performs even when nothing matches.
203 // Most requests carry clean labels, so this is the common case.
204 if !s.contains("..") {
205 return s.to_string();
206 }
207 let mut cur = s.to_string();
208 loop {
209 let next = cur.replace("..", "");
210 if next == cur {
211 return next;
212 }
213 cur = next;
214 }
215}
216
217// ---------------------------------------------------------------------------
218// Unit tests — exercise helpers; integration coverage lives in
219// `tests/tenancy_subdomain.rs`.
220// ---------------------------------------------------------------------------
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn strip_port_handles_missing_port() {
228 assert_eq!(strip_port("example.org"), "example.org");
229 assert_eq!(strip_port("example.org:8080"), "example.org");
230 }
231
232 #[test]
233 fn scrub_dotdot_is_double_pass() {
234 assert_eq!(scrub_dotdot("al..ice"), "alice");
235 // `....` would become `..` after a single pass; second pass
236 // must strip it completely.
237 assert_eq!(scrub_dotdot("al....ice"), "alice");
238 assert_eq!(scrub_dotdot("safe"), "safe");
239 }
240
241 /// Sprint 12 security hardening (JSS commit 2569811): the bypass
242 /// `"....//foo"` must NOT produce `"../foo"` — iterative removal
243 /// collapses all `..` sequences so the final result is `"//foo"`.
244 #[test]
245 fn scrub_dotdot_iterative_defeats_bypass() {
246 // `....//foo` → single pass yields `..//foo` (still has `..`);
247 // iterative pass yields `//foo` (all `..` removed).
248 let result = scrub_dotdot("....//foo");
249 assert!(
250 !result.contains(".."),
251 "iterative scrub must eliminate all `..`: got {result:?}"
252 );
253 assert_eq!(result, "//foo");
254 }
255
256 /// Verify the full subdomain resolver rejects the bypass attempt.
257 /// `"....//foo"` as a subdomain label, after scrubbing, contains `/`
258 /// and therefore falls back to path mode (pod: None).
259 #[test]
260 fn subdomain_rejects_dotdot_bypass_as_pod() {
261 let r = SubdomainResolver {
262 base_domain: "pods.example.com".into(),
263 };
264 // Host header: `....//foo.pods.example.com`
265 let got = r.resolve("....//foo.pods.example.com", "/index.html");
266 assert_eq!(got.pod, None, "bypass attempt must not produce a pod name");
267 }
268
269 #[test]
270 fn path_resolver_ignores_host() {
271 let r = PathResolver;
272 let a = r.resolve("anything", "/x");
273 let b = r.resolve("", "/x");
274 assert_eq!(a, b);
275 assert_eq!(a.pod, None);
276 }
277
278 // -----------------------------------------------------------------
279 // Sprint 11 (row 125, 162): subdomain hardening — JSS PR #307.
280 // -----------------------------------------------------------------
281
282 #[test]
283 fn subdomain_extracts_pod_name() {
284 let r = SubdomainResolver {
285 base_domain: "pods.example.com".into(),
286 };
287 let got = r.resolve("alice.pods.example.com", "/index.html");
288 assert_eq!(got.pod.as_deref(), Some("alice"));
289 assert_eq!(got.storage_path, "/index.html");
290 }
291
292 /// Performance hardening (2026-10-24): the allocation-lean rewrite
293 /// of `resolve` must keep pod matching ASCII-case-insensitive on
294 /// both sides — mixed-case hosts still yield lowercase pod names
295 /// and a mixed-case configured base still matches — while the
296 /// separator dot stays mandatory (`evilpods.example.com` must not
297 /// match base `pods.example.com`).
298 #[test]
299 fn subdomain_matching_is_ascii_case_insensitive() {
300 let r = SubdomainResolver {
301 base_domain: "PODS.example.com".into(),
302 };
303 let got = r.resolve("ALICE.PODS.example.com", "/index.html");
304 assert_eq!(got.pod.as_deref(), Some("alice"));
305 assert_eq!(got.storage_path, "/index.html");
306
307 // Bare base domain in mixed case → root pod, not a fallback.
308 let got = r.resolve("pods.EXAMPLE.com", "/hello");
309 assert_eq!(got.pod, None);
310 assert_eq!(got.storage_path, "/hello");
311
312 // Separator dot still required: no dot, no pod.
313 let got = r.resolve("evilpods.example.com", "/x");
314 assert_eq!(got.pod, None);
315 }
316
317 #[test]
318 fn subdomain_file_like_label_passes_through() {
319 // PR #307 regression: `favicon.ico.pods.example.com` must NOT
320 // be rewritten to a pod named `favicon.ico`.
321 let r = SubdomainResolver {
322 base_domain: "pods.example.com".into(),
323 };
324 let got = r.resolve("favicon.ico.pods.example.com", "/");
325 assert_eq!(got.pod, None, "file-like label must pass through");
326 assert_eq!(got.storage_path, "/");
327 }
328
329 #[test]
330 fn subdomain_html_label_passes_through() {
331 let r = SubdomainResolver {
332 base_domain: "pods.example.com".into(),
333 };
334 let got = r.resolve("index.html.pods.example.com", "/");
335 assert_eq!(got.pod, None);
336 }
337
338 #[test]
339 fn subdomain_base_domain_root() {
340 let r = SubdomainResolver {
341 base_domain: "pods.example.com".into(),
342 };
343 let got = r.resolve("pods.example.com", "/hello");
344 assert_eq!(got.pod, None);
345 assert_eq!(got.storage_path, "/hello");
346 }
347
348 #[test]
349 fn is_file_like_label_matches_known_extensions() {
350 assert!(is_file_like_label("favicon.ico"));
351 assert!(is_file_like_label("style.css"));
352 assert!(is_file_like_label("bundle.js"));
353 assert!(is_file_like_label("icon.SVG"));
354 assert!(is_file_like_label("profile.jsonld"));
355 assert!(
356 !is_file_like_label("hero.webp"),
357 "unknown ext must not match"
358 );
359 assert!(!is_file_like_label("alice"));
360 assert!(!is_file_like_label("bob-smith"));
361 // A label with a dot but unknown extension must not match.
362 assert!(!is_file_like_label("foo.bar"));
363 }
364}