Skip to main content

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}