http_tunnel_handler/
content_rewrite.rs

1//! Content rewriting module for path-based routing
2//!
3//! This module handles rewriting absolute paths in response content to include
4//! the tunnel ID prefix. This is necessary for path-based routing where the
5//! tunnel ID is part of the URL path.
6//!
7//! For example, if a tunnel ID is "abc123" and the local service returns HTML
8//! with `href="/api/users"`, it needs to be rewritten to `href="/abc123/api/users"`
9//! so that the browser sends requests to the correct tunnel path.
10
11use anyhow::Result;
12use once_cell::sync::Lazy;
13use regex::{Captures, Regex};
14use tracing::{debug, warn};
15
16/// Strategy for rewriting content
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum RewriteStrategy {
19    /// No rewriting (pass through unchanged)
20    None,
21    /// HTML: inject <base> tag only
22    BaseTag,
23    /// HTML: rewrite all absolute paths
24    #[default]
25    FullRewrite,
26}
27
28/// Check if content type should be rewritten
29pub fn should_rewrite_content(content_type: &str) -> bool {
30    let content_type_lower = content_type.to_lowercase();
31    matches!(
32        content_type_lower.split(';').next().unwrap_or("").trim(),
33        "text/html"
34            | "text/css"
35            | "application/javascript"
36            | "text/javascript"
37            | "application/json"
38    )
39}
40
41/// Main entry point for content rewriting
42pub fn rewrite_response_content(
43    body: &str,
44    content_type: &str,
45    tunnel_id: &str,
46    strategy: RewriteStrategy,
47) -> Result<(String, bool)> {
48    if !should_rewrite_content(content_type) {
49        return Ok((body.to_string(), false));
50    }
51
52    let prefix = format!("/{}", tunnel_id);
53    let content_type_lower = content_type.to_lowercase();
54    let mime_type = content_type_lower.split(';').next().unwrap_or("").trim();
55
56    let result = match (mime_type, strategy) {
57        (_, RewriteStrategy::None) => {
58            debug!("Content rewriting disabled by strategy");
59            return Ok((body.to_string(), false));
60        }
61        ("text/html", RewriteStrategy::BaseTag) => inject_base_tag(body, &prefix),
62        ("text/html", RewriteStrategy::FullRewrite) => rewrite_html(body, &prefix),
63        ("text/css", _) => rewrite_css(body, &prefix),
64        ("application/javascript" | "text/javascript", _) => {
65            // JavaScript rewriting is complex and risky, skip for now
66            debug!("Skipping JavaScript rewriting (not implemented)");
67            return Ok((body.to_string(), false));
68        }
69        ("application/json", _) => rewrite_json(body, &prefix),
70        _ => {
71            return Ok((body.to_string(), false));
72        }
73    };
74
75    let rewritten = result?;
76    let was_rewritten = rewritten != body;
77
78    if was_rewritten {
79        debug!(
80            "Rewrote {} content: {} bytes -> {} bytes",
81            mime_type,
82            body.len(),
83            rewritten.len()
84        );
85    }
86
87    Ok((rewritten, was_rewritten))
88}
89
90// Regex patterns (compiled once, reused many times)
91static HTML_HREF_REGEX: Lazy<Regex> =
92    Lazy::new(|| Regex::new(r#"href="(/[^"]*)""#).expect("Invalid regex"));
93static HTML_SRC_REGEX: Lazy<Regex> =
94    Lazy::new(|| Regex::new(r#"src="(/[^"]*)""#).expect("Invalid regex"));
95static HTML_ACTION_REGEX: Lazy<Regex> =
96    Lazy::new(|| Regex::new(r#"action="(/[^"]*)""#).expect("Invalid regex"));
97
98// Match url() with various quote styles
99static CSS_URL_SINGLE_QUOTE: Lazy<Regex> =
100    Lazy::new(|| Regex::new(r#"url\('(/[^']+)'\)"#).expect("Invalid regex"));
101static CSS_URL_DOUBLE_QUOTE: Lazy<Regex> =
102    Lazy::new(|| Regex::new(r#"url\("(/[^"]+)"\)"#).expect("Invalid regex"));
103static CSS_URL_NO_QUOTE: Lazy<Regex> =
104    Lazy::new(|| Regex::new(r#"url\((/[^)]+)\)"#).expect("Invalid regex"));
105
106static JSON_PATH_REGEX: Lazy<Regex> =
107    Lazy::new(|| Regex::new(r#""(/[a-zA-Z0-9/_-]+)""#).expect("Invalid regex"));
108
109/// Inject <base> tag into HTML to set base path
110/// This is a simpler approach that works for many HTML pages
111fn inject_base_tag(html: &str, prefix: &str) -> Result<String> {
112    // Look for <head> tag (case-insensitive)
113    let head_regex = Regex::new(r"(?i)<head[^>]*>")?;
114
115    if let Some(mat) = head_regex.find(html) {
116        let insert_pos = mat.end();
117        let base_tag = format!(r#"<base href="{}/""#, prefix);
118        let mut result = html.to_string();
119        result.insert_str(insert_pos, &base_tag);
120        return Ok(result);
121    }
122
123    // If no <head> tag found, try to inject after <html>
124    let html_regex = Regex::new(r"(?i)<html[^>]*>")?;
125    if let Some(mat) = html_regex.find(html) {
126        let insert_pos = mat.end();
127        let base_tag = format!(r#"<head><base href="{}/""></head>"#, prefix);
128        let mut result = html.to_string();
129        result.insert_str(insert_pos, &base_tag);
130        return Ok(result);
131    }
132
133    warn!("Could not find <head> or <html> tag for base tag injection");
134    Ok(html.to_string())
135}
136
137/// Inject tunnel context script into HTML
138/// This provides a global JavaScript variable that client code can use for dynamic URL construction
139fn inject_tunnel_context(html: &str, tunnel_id: &str) -> Result<String> {
140    let head_regex = Regex::new(r"(?i)<head[^>]*>")?;
141
142    // Script that provides tunnel context to client-side JavaScript
143    let context_script = format!(
144        r#"<script>
145// HTTP Tunnel Context - provides tunnel ID for dynamic URL construction
146window.__TUNNEL_CONTEXT__ = {{
147    tunnelId: '{}',
148    basePath: '{}',
149    // Helper function to construct URLs with tunnel prefix
150    url: function(path) {{
151        if (!path) return this.basePath;
152        // Remove leading slash if present
153        const cleanPath = path.startsWith('/') ? path.substring(1) : path;
154        return this.basePath + '/' + cleanPath;
155    }},
156    // Get the full base URL including tunnel prefix
157    getBaseUrl: function() {{
158        return window.location.origin + this.basePath;
159    }}
160}};
161// Also set base path as a simple variable for backwards compatibility
162window.__TUNNEL_BASE_PATH__ = '{}';
163</script>"#,
164        tunnel_id, tunnel_id, tunnel_id
165    );
166
167    if let Some(mat) = head_regex.find(html) {
168        let insert_pos = mat.end();
169        let mut result = html.to_string();
170        result.insert_str(insert_pos, &context_script);
171        return Ok(result);
172    }
173
174    // If no <head> tag, try after <html>
175    let html_regex = Regex::new(r"(?i)<html[^>]*>")?;
176    if let Some(mat) = html_regex.find(html) {
177        let insert_pos = mat.end();
178        let script_with_head = format!("<head>{}</head>", context_script);
179        let mut result = html.to_string();
180        result.insert_str(insert_pos, &script_with_head);
181        return Ok(result);
182    }
183
184    // If no structure found, prepend to document
185    Ok(format!("{}{}", context_script, html))
186}
187
188/// Rewrite absolute paths in HTML attributes and inline JavaScript
189fn rewrite_html(body: &str, prefix: &str) -> Result<String> {
190    // Helper function to check if path should be rewritten
191    let should_rewrite_path = |path: &str| -> bool {
192        // Don't rewrite if:
193        // - Already prefixed
194        // - External URL (http://, https://)
195        // - Protocol-relative URL (//)
196        // - Data URL (data:)
197        // - Anchor only (#)
198        // - Empty
199        if path.is_empty() || path.starts_with('#') {
200            return false;
201        }
202        if path.starts_with("http://")
203            || path.starts_with("https://")
204            || path.starts_with("//")
205            || path.starts_with("data:")
206        {
207            return false;
208        }
209        // Check if already prefixed
210        if path.starts_with(&format!("{}/", prefix)) || path == prefix {
211            return false;
212        }
213        true
214    };
215
216    // Rewrite href attributes
217    let result = HTML_HREF_REGEX.replace_all(body, |caps: &Captures| {
218        let path = &caps[1];
219        if should_rewrite_path(path) {
220            format!(r#"href="{}{}""#, prefix, path)
221        } else {
222            caps[0].to_string()
223        }
224    });
225
226    // Rewrite src attributes
227    let result = HTML_SRC_REGEX.replace_all(&result, |caps: &Captures| {
228        let path = &caps[1];
229        if should_rewrite_path(path) {
230            format!(r#"src="{}{}""#, prefix, path)
231        } else {
232            caps[0].to_string()
233        }
234    });
235
236    // Rewrite action attributes
237    let result = HTML_ACTION_REGEX.replace_all(&result, |caps: &Captures| {
238        let path = &caps[1];
239        if should_rewrite_path(path) {
240            format!(r#"action="{}{}""#, prefix, path)
241        } else {
242            caps[0].to_string()
243        }
244    });
245
246    // Rewrite JavaScript string literals (for inline scripts)
247    // This is conservative and only rewrites obvious patterns
248    let result = rewrite_inline_javascript(&result, prefix)?;
249
250    // Inject tunnel context for dynamic JavaScript URL construction
251    // This enables client-side code to build URLs correctly
252    let tunnel_id = prefix.trim_start_matches('/');
253    let result = inject_tunnel_context(&result, tunnel_id)?;
254
255    Ok(result)
256}
257
258/// Rewrite JavaScript string literals in inline scripts
259/// This handles common patterns like: url: '/api/path', fetch('/api/path'), etc.
260fn rewrite_inline_javascript(html: &str, prefix: &str) -> Result<String> {
261    // Match JavaScript string literals with absolute paths
262    // Patterns: 'string', "string" with absolute paths
263    let js_single_quote = Regex::new(r#"'(/[a-zA-Z0-9/_\-\.]+)'"#)?;
264    let js_double_quote = Regex::new(r#""(/[a-zA-Z0-9/_\-\.]+)""#)?;
265
266    let should_rewrite_js_path = |path: &str| -> bool {
267        // Only rewrite if it looks like an API path or common web paths
268        // Don't rewrite very short paths or paths that might be variable names
269        if path.len() < 2 {
270            return false;
271        }
272        // Check if already prefixed
273        if path.starts_with(&format!("{}/", prefix)) || path == prefix {
274            return false;
275        }
276        // Only rewrite paths that look like web resources
277        path.starts_with("/api")
278            || path.starts_with("/docs")
279            || path.starts_with("/openapi")
280            || path.starts_with("/swagger")
281            || path.starts_with("/v1")
282            || path.starts_with("/v2")
283            || path.starts_with("/v3")
284            || path.ends_with(".json")
285            || path.ends_with(".yaml")
286            || path.ends_with(".yml")
287    };
288
289    // Rewrite single-quoted strings
290    let result = js_single_quote.replace_all(html, |caps: &Captures| {
291        let path = &caps[1];
292        if should_rewrite_js_path(path) {
293            format!("'{}{}'", prefix, path)
294        } else {
295            caps[0].to_string()
296        }
297    });
298
299    // Rewrite double-quoted strings
300    let result = js_double_quote.replace_all(&result, |caps: &Captures| {
301        let path = &caps[1];
302        if should_rewrite_js_path(path) {
303            format!("\"{}{}\"", prefix, path)
304        } else {
305            caps[0].to_string()
306        }
307    });
308
309    Ok(result.into_owned())
310}
311
312/// Rewrite url() references in CSS
313fn rewrite_css(body: &str, prefix: &str) -> Result<String> {
314    let should_rewrite = |path: &str| -> bool {
315        !path.starts_with("http://")
316            && !path.starts_with("https://")
317            && !path.starts_with("//")
318            && !path.starts_with("data:")
319            && !path.starts_with(&format!("{}/", prefix))
320    };
321
322    // Process single quotes
323    let result = CSS_URL_SINGLE_QUOTE.replace_all(body, |caps: &Captures| {
324        let path = &caps[1];
325        if should_rewrite(path) {
326            format!("url('{}{}')", prefix, path)
327        } else {
328            caps[0].to_string()
329        }
330    });
331
332    // Process double quotes
333    let result = CSS_URL_DOUBLE_QUOTE.replace_all(&result, |caps: &Captures| {
334        let path = &caps[1];
335        if should_rewrite(path) {
336            format!("url(\"{}{}\")", prefix, path)
337        } else {
338            caps[0].to_string()
339        }
340    });
341
342    // Process no quotes (must be last to avoid matching already-processed URLs)
343    let result = CSS_URL_NO_QUOTE.replace_all(&result, |caps: &Captures| {
344        let path = caps[1].trim();
345        // Skip if it has quotes (already processed) or is external
346        if path.starts_with('\'') || path.starts_with('"') || !should_rewrite(path) {
347            return caps[0].to_string();
348        }
349        format!("url({}{})", prefix, path)
350    });
351
352    Ok(result.into_owned())
353}
354
355/// Rewrite absolute paths in JSON content
356/// This is conservative and only rewrites obvious path-like strings
357/// Also handles OpenAPI spec's servers field
358fn rewrite_json(body: &str, prefix: &str) -> Result<String> {
359    // First, handle OpenAPI servers field specially
360    // "servers": [{"url": "/api"}] or "servers": [{"url": "https://example.com"}]
361    let servers_regex = Regex::new(r#""servers"\s*:\s*\[\s*\{\s*"url"\s*:\s*"([^"]*)""#)?;
362
363    let result = servers_regex.replace_all(body, |caps: &Captures| {
364        let url = &caps[1];
365
366        // If it's a relative path (starts with /), rewrite it
367        if url.starts_with('/') && !url.starts_with(&format!("{}/", prefix)) {
368            format!(r#""servers": [{{"url": "{}{}""#, prefix, url)
369        } else if url.starts_with("http://") || url.starts_with("https://") {
370            // It's a full URL - don't rewrite
371            caps[0].to_string()
372        } else {
373            caps[0].to_string()
374        }
375    });
376
377    // Then handle general JSON path rewriting
378    let result = JSON_PATH_REGEX.replace_all(&result, |caps: &Captures| {
379        let path = &caps[1];
380
381        // Don't rewrite if:
382        // - Already prefixed
383        // - Looks like a URL scheme (http:, https:, etc.)
384        // - Too short to be a meaningful path
385        if path.len() < 2 {
386            return caps[0].to_string();
387        }
388        if path.starts_with(&format!("{}/", prefix)) || path == prefix {
389            return caps[0].to_string();
390        }
391        // Check if it looks like a URL scheme
392        if path.contains("://") {
393            return caps[0].to_string();
394        }
395
396        // Only rewrite if it looks like an API path (starts with /api, /v1, etc.)
397        // or is in a known OpenAPI field
398        let path_lower = path.to_lowercase();
399        if path_lower.starts_with("/api")
400            || path_lower.starts_with("/v1")
401            || path_lower.starts_with("/v2")
402            || path_lower.starts_with("/v3")
403            || path_lower.starts_with("/docs")
404            || path_lower.starts_with("/openapi")
405            || path_lower.starts_with("/swagger")
406            || path_lower.starts_with("/todos")
407        // Common API path
408        {
409            format!(r#""{}{}""#, prefix, path)
410        } else {
411            caps[0].to_string()
412        }
413    });
414
415    Ok(result.into_owned())
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_should_rewrite_content() {
424        assert!(should_rewrite_content("text/html"));
425        assert!(should_rewrite_content("text/html; charset=utf-8"));
426        assert!(should_rewrite_content("text/css"));
427        assert!(should_rewrite_content("application/json"));
428        assert!(should_rewrite_content("application/javascript"));
429        assert!(should_rewrite_content("text/javascript"));
430
431        assert!(!should_rewrite_content("image/png"));
432        assert!(!should_rewrite_content("application/octet-stream"));
433        assert!(!should_rewrite_content("video/mp4"));
434    }
435
436    #[test]
437    fn test_inject_base_tag() {
438        let html = r#"<html><head><title>Test</title></head><body></body></html>"#;
439        let result = inject_base_tag(html, "/abc123").unwrap();
440        assert!(result.contains(r#"<base href="/abc123/""#));
441        assert!(result.contains("<title>Test</title>"));
442    }
443
444    #[test]
445    fn test_inject_base_tag_no_head() {
446        let html = r#"<html><body>No head tag</body></html>"#;
447        let result = inject_base_tag(html, "/abc123").unwrap();
448        assert!(result.contains(r#"<base href="/abc123/""#));
449    }
450
451    #[test]
452    fn test_rewrite_html_href() {
453        let html = r#"<a href="/api/users">Users</a>"#;
454        let result = rewrite_html(html, "/abc123").unwrap();
455        assert!(result.contains(r#"<a href="/abc123/api/users">Users</a>"#));
456        assert!(result.contains("window.__TUNNEL_CONTEXT__"));
457    }
458
459    #[test]
460    fn test_rewrite_html_src() {
461        let html = r#"<img src="/images/logo.png">"#;
462        let result = rewrite_html(html, "/abc123").unwrap();
463        assert!(result.contains(r#"<img src="/abc123/images/logo.png">"#));
464    }
465
466    #[test]
467    fn test_rewrite_html_action() {
468        let html = r#"<form action="/submit">...</form>"#;
469        let result = rewrite_html(html, "/abc123").unwrap();
470        assert!(result.contains(r#"<form action="/abc123/submit">...</form>"#));
471    }
472
473    #[test]
474    fn test_dont_rewrite_external_url() {
475        let html = r#"<a href="https://example.com/page">External</a>"#;
476        let result = rewrite_html(html, "/abc123").unwrap();
477        // External URL should be unchanged
478        assert!(result.contains(r#"href="https://example.com/page""#));
479    }
480
481    #[test]
482    fn test_dont_rewrite_protocol_relative_url() {
483        let html = r#"<script src="//cdn.example.com/script.js"></script>"#;
484        let result = rewrite_html(html, "/abc123").unwrap();
485        // Protocol-relative URL should be unchanged
486        assert!(result.contains(r#"src="//cdn.example.com/script.js""#));
487    }
488
489    #[test]
490    fn test_dont_rewrite_data_url() {
491        let html = r#"<img src="data:image/png;base64,iVBOR...">"#;
492        let result = rewrite_html(html, "/abc123").unwrap();
493        // Data URL should be unchanged
494        assert!(result.contains(r#"src="data:image/png;base64,iVBOR...""#));
495    }
496
497    #[test]
498    fn test_dont_rewrite_anchor() {
499        let html = "<a href=\"#section\">Jump</a>";
500        let result = rewrite_html(html, "/abc123").unwrap();
501        // Anchor should be unchanged
502        assert!(result.contains("href=\"#section\""));
503    }
504
505    #[test]
506    fn test_dont_double_prefix() {
507        let html = r#"<a href="/abc123/api/users">Already prefixed</a>"#;
508        let result = rewrite_html(html, "/abc123").unwrap();
509        // Should not double-prefix
510        assert!(result.contains(r#"href="/abc123/api/users""#));
511        assert!(!result.contains(r#"href="/abc123/abc123/api/users""#));
512    }
513
514    #[test]
515    fn test_rewrite_css_url() {
516        let css = r#"background: url('/images/bg.png');"#;
517        let result = rewrite_css(css, "/abc123").unwrap();
518        assert_eq!(result, r#"background: url('/abc123/images/bg.png');"#);
519    }
520
521    #[test]
522    fn test_rewrite_css_url_no_quotes() {
523        let css = r#"background: url(/images/bg.png);"#;
524        let result = rewrite_css(css, "/abc123").unwrap();
525        assert_eq!(result, r#"background: url(/abc123/images/bg.png);"#);
526    }
527
528    #[test]
529    fn test_rewrite_css_url_double_quotes() {
530        let css = r#"background: url("/images/bg.png");"#;
531        let result = rewrite_css(css, "/abc123").unwrap();
532        assert_eq!(result, r#"background: url("/abc123/images/bg.png");"#);
533    }
534
535    #[test]
536    fn test_dont_rewrite_css_external_url() {
537        let css = r#"background: url('https://cdn.example.com/bg.png');"#;
538        let result = rewrite_css(css, "/abc123").unwrap();
539        assert_eq!(result, css);
540    }
541
542    #[test]
543    fn test_rewrite_json_api_path() {
544        let json = r#"{"url": "/api/users"}"#;
545        let result = rewrite_json(json, "/abc123").unwrap();
546        assert_eq!(result, r#"{"url": "/abc123/api/users"}"#);
547    }
548
549    #[test]
550    fn test_rewrite_json_versioned_api() {
551        let json = r#"{"baseUrl": "/v1/resources"}"#;
552        let result = rewrite_json(json, "/abc123").unwrap();
553        assert_eq!(result, r#"{"baseUrl": "/abc123/v1/resources"}"#);
554    }
555
556    #[test]
557    fn test_dont_rewrite_json_arbitrary_path() {
558        let json = r#"{"path": "/some/random/path"}"#;
559        let result = rewrite_json(json, "/abc123").unwrap();
560        // Should not rewrite paths that don't look like API paths
561        assert_eq!(result, json);
562    }
563
564    #[test]
565    fn test_dont_rewrite_json_url_scheme() {
566        let json = r#"{"url": "https://example.com/api"}"#;
567        let result = rewrite_json(json, "/abc123").unwrap();
568        assert_eq!(result, json);
569    }
570
571    #[test]
572    fn test_rewrite_response_content_html_full() {
573        let html = r#"<html><head></head><body><a href="/api">API</a></body></html>"#;
574        let (result, rewritten) =
575            rewrite_response_content(html, "text/html", "abc123", RewriteStrategy::FullRewrite)
576                .unwrap();
577        assert!(rewritten);
578        assert!(result.contains(r#"href="/abc123/api""#));
579    }
580
581    #[test]
582    fn test_rewrite_response_content_html_base_tag() {
583        let html = r#"<html><head></head><body><a href="/api">API</a></body></html>"#;
584        let (result, rewritten) =
585            rewrite_response_content(html, "text/html", "abc123", RewriteStrategy::BaseTag)
586                .unwrap();
587        assert!(rewritten);
588        assert!(result.contains(r#"<base href="/abc123/""#));
589    }
590
591    #[test]
592    fn test_rewrite_response_content_no_rewrite_strategy() {
593        let html = r#"<a href="/api">API</a>"#;
594        let (result, rewritten) =
595            rewrite_response_content(html, "text/html", "abc123", RewriteStrategy::None).unwrap();
596        assert!(!rewritten);
597        assert_eq!(result, html);
598    }
599
600    #[test]
601    fn test_rewrite_response_content_css() {
602        let css = r#"div { background: url('/img/bg.png'); }"#;
603        let (result, rewritten) =
604            rewrite_response_content(css, "text/css", "abc123", RewriteStrategy::FullRewrite)
605                .unwrap();
606        assert!(rewritten);
607        assert!(result.contains("/abc123/img/bg.png"));
608    }
609
610    #[test]
611    fn test_rewrite_response_content_non_rewritable() {
612        let content = "binary data";
613        let (result, rewritten) =
614            rewrite_response_content(content, "image/png", "abc123", RewriteStrategy::FullRewrite)
615                .unwrap();
616        assert!(!rewritten);
617        assert_eq!(result, content);
618    }
619
620    #[test]
621    fn test_content_type_with_charset() {
622        assert!(should_rewrite_content("text/html; charset=utf-8"));
623        assert!(should_rewrite_content("application/json; charset=utf-8"));
624        assert!(should_rewrite_content(
625            "text/html; charset=utf-8; boundary=something"
626        ));
627    }
628
629    #[test]
630    fn test_rewrite_inline_javascript() {
631        let html = "<script>\nconst ui = { url: '/openapi.json', path: '/api/v1' };\n</script>";
632        let result = rewrite_html(html, "/abc123").unwrap();
633        assert!(result.contains("'/abc123/openapi.json'"));
634        assert!(result.contains("'/abc123/api/v1'"));
635    }
636
637    #[test]
638    fn test_rewrite_swagger_config() {
639        let html = r#"<script>
640    const ui = SwaggerUIBundle({
641        url: '/openapi.json',
642        oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
643    })
644    </script>"#;
645        let result = rewrite_html(html, "/abc123").unwrap();
646        assert!(result.contains("url: '/abc123/openapi.json'"));
647        assert!(result.contains("+ '/abc123/docs/oauth2-redirect'"));
648    }
649
650    #[test]
651    fn test_dont_rewrite_short_js_paths() {
652        let html = "<script>const x = '/';</script>";
653        let result = rewrite_html(html, "/abc123").unwrap();
654        // Very short paths like '/' should not be rewritten
655        assert!(result.contains("const x = '/';"));
656    }
657
658    #[test]
659    fn test_inject_tunnel_context() {
660        let html = "<html><head></head><body></body></html>";
661        let result = rewrite_html(html, "/abc123").unwrap();
662        // Should inject tunnel context script
663        assert!(result.contains("window.__TUNNEL_CONTEXT__"));
664        assert!(result.contains("tunnelId: 'abc123'"));
665        assert!(result.contains("basePath: 'abc123'"));
666        assert!(result.contains("window.__TUNNEL_BASE_PATH__"));
667    }
668
669    #[test]
670    fn test_complex_html_document() {
671        let html = "<!DOCTYPE html>\n<html>\n<head>\n    <title>Test Page</title>\n    <link rel=\"stylesheet\" href=\"/static/style.css\">\n    <script src=\"/static/app.js\"></script>\n</head>\n<body>\n    <a href=\"/api/users\">Users</a>\n    <a href=\"https://external.com\">External</a>\n    <a href=\"#section\">Anchor</a>\n    <img src=\"/images/logo.png\">\n    <form action=\"/submit\" method=\"POST\">\n        <input type=\"submit\">\n    </form>\n</body>\n</html>";
672
673        let result = rewrite_html(html, "/abc123").unwrap();
674
675        // Should rewrite local paths
676        assert!(result.contains("href=\"/abc123/static/style.css\""));
677        assert!(result.contains("src=\"/abc123/static/app.js\""));
678        assert!(result.contains("href=\"/abc123/api/users\""));
679        assert!(result.contains("src=\"/abc123/images/logo.png\""));
680        assert!(result.contains("action=\"/abc123/submit\""));
681
682        // Should NOT rewrite external URLs and anchors
683        assert!(result.contains("href=\"https://external.com\""));
684        assert!(result.contains("href=\"#section\""));
685    }
686}