Skip to main content

atmos/os_lib/web_engine/
url_resolve.rs

1// web_engine/url_resolve.rs - 外部CSS/リソースの相対URL解決。
2// 依存: alloc のみ(ホスト側ユニットテスト tests/ から直接 #[path] 取り込み可能にするため、
3// crate::os_lib/crate::kernel 側の型には一切依存しない)。
4
5use alloc::format;
6use alloc::string::String;
7use alloc::vec::Vec;
8
9/// `base_url`(絶対URL、例: `https://cdnjs.cloudflare.com/.../css/all.min.css`)を
10/// 基点として、CSS内の相対URL参照(`../webfonts/x.woff2` 等)を絶対URLへ解決する。
11/// 外部CSSファイル自身の場所を基準にする必要がある(HTMLドキュメント自身の
12/// ホストとは異なることが多い。例: cdnjs.cloudflare.com上のCSSが参照する
13/// webfontはcdnjs.cloudflare.com上の相対パス)。
14pub fn resolve_url(base_url: &str, rel: &str) -> String {
15    let rel = rel.trim();
16    if rel.is_empty() {
17        return String::from(rel);
18    }
19    if rel.starts_with("http://") || rel.starts_with("https://") || rel.starts_with("data:") {
20        return String::from(rel);
21    }
22    let (scheme, rest) = if let Some(r) = base_url.strip_prefix("https://") {
23        ("https://", r)
24    } else if let Some(r) = base_url.strip_prefix("http://") {
25        ("http://", r)
26    } else {
27        return String::from(rel);
28    };
29    let host = match rest.find('/') {
30        Some(idx) => rest.get(..idx).unwrap_or(rest),
31        None => rest,
32    };
33    if let Some(no_scheme) = rel.strip_prefix("//") {
34        return format!("{}{}", scheme, no_scheme);
35    }
36    if let Some(abs_path) = rel.strip_prefix('/') {
37        return format!("{}{}/{}", scheme, host, abs_path);
38    }
39    // ベースURLのパス部分から、末尾のファイル名を除いたディレクトリを求める。
40    let base_path = match rest.find('/') {
41        Some(idx) => rest.get(idx..).unwrap_or("/"),
42        None => "/",
43    };
44    let mut dir_segments: Vec<&str> = base_path.split('/').collect();
45    dir_segments.pop(); // 末尾のファイル名部分を除去
46    for seg in rel.split('/') {
47        match seg {
48            "." | "" => {}
49            ".." => {
50                if dir_segments.len() > 1 {
51                    dir_segments.pop();
52                }
53            }
54            s => dir_segments.push(s),
55        }
56    }
57    let joined_path = dir_segments.join("/");
58    format!("{}{}{}", scheme, host, joined_path)
59}
60
61/// 外部CSSテキスト中の `url(...)` 参照を、そのCSSファイル自身の絶対URL
62/// (`css_base_url`)を基準に絶対URLへ書き換える。`@font-face { src: url(...) }`
63/// や `background-image: url(...)` 等、CSS内の相対パスは常にCSSファイル自身の
64/// 場所基準で解決されるべき(HTMLドキュメントのURLではない)という仕様に従う。
65pub fn rewrite_css_urls(css: &str, css_base_url: &str) -> String {
66    if !css.contains("url(") {
67        return String::from(css);
68    }
69    let mut out = String::with_capacity(css.len());
70    let mut rest = css;
71    while let Some(idx) = rest.find("url(") {
72        if let Some(prefix) = rest.get(..idx) {
73            out.push_str(prefix);
74        }
75        out.push_str("url(");
76        let after_url = match rest.get(idx + 4..) {
77            Some(s) => s,
78            None => break,
79        };
80        let bytes = after_url.as_bytes();
81        let mut depth = 1i32;
82        let mut i = 0usize;
83        while i < bytes.len() && depth > 0 {
84            match bytes[i] {
85                b'(' => depth += 1,
86                b')' => depth -= 1,
87                _ => {}
88            }
89            if depth > 0 {
90                i += 1;
91            }
92        }
93        let inner = after_url.get(..i).unwrap_or("");
94        let trimmed = inner.trim();
95        let (quote, unquoted) = if (trimmed.starts_with('"') && trimmed.ends_with('"'))
96            || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
97        {
98            let q = trimmed.get(..1).unwrap_or("");
99            (q, trimmed.get(1..trimmed.len() - 1).unwrap_or(""))
100        } else {
101            ("", trimmed)
102        };
103        let resolved = resolve_url(css_base_url, unquoted);
104        out.push_str(quote);
105        out.push_str(&resolved);
106        out.push_str(quote);
107        if i < bytes.len() {
108            out.push(')');
109            i += 1;
110        }
111        rest = after_url.get(i..).unwrap_or("");
112    }
113    out.push_str(rest);
114    out
115}
116
117/// ネットワーク切断(TLS読み取りエラー等)で外部CSSが波括弧の途中で
118/// 打ち切られた場合、そのまま後続のCSS(他の`<link>`分)と連結すると、
119/// 閉じられなかった`{`のせいでパーサーが「まだ前のルールの中にいる」と
120/// 誤認し、以降の本物のルールを大量に読み飛ばしてしまう(2026-07-25発見)。
121/// 文字列/コメントを簡易的に読み飛ばしつつ波括弧の深さを追跡し、深さが
122/// 0に戻った最後の位置(=最後まで完結した最後のトップレベルルールの
123/// 直後)までに安全側で切り詰める。既に完全にバランスしている場合は
124/// 元の文字列をそのまま返す。
125pub fn truncate_to_balanced_css(css: &str) -> &str {
126    let bytes = css.as_bytes();
127    let mut depth: i32 = 0;
128    let mut last_balanced_end = 0usize;
129    let mut i = 0usize;
130    while i < bytes.len() {
131        match bytes[i] {
132            b'/' if bytes.get(i + 1) == Some(&b'*') => {
133                // ブロックコメント内の { } を誤ってカウントしないよう読み飛ばす。
134                i += 2;
135                while i < bytes.len() && !(bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/')) {
136                    i += 1;
137                }
138                i = (i + 2).min(bytes.len());
139                continue;
140            }
141            b'"' | b'\'' => {
142                // 文字列リテラル内の { } を誤ってカウントしないよう読み飛ばす。
143                let quote = bytes[i];
144                i += 1;
145                while i < bytes.len() && bytes[i] != quote {
146                    if bytes[i] == b'\\' {
147                        i += 1;
148                    }
149                    i += 1;
150                }
151                i = (i + 1).min(bytes.len());
152                continue;
153            }
154            b'{' => {
155                depth += 1;
156                i += 1;
157            }
158            b'}' => {
159                depth -= 1;
160                i += 1;
161                if depth <= 0 {
162                    depth = 0;
163                    last_balanced_end = i;
164                }
165            }
166            _ => {
167                i += 1;
168            }
169        }
170    }
171    if depth == 0 {
172        css
173    } else {
174        css.get(..last_balanced_end).unwrap_or("")
175    }
176}
177
178/// 取得先の `(https か, ホスト, パス)` を求める。
179///
180/// `https://h/p`・`http://h/p`・`//h/p`(プロトコル相対)・`/p`(ルート相対)を
181/// 扱う。現在のページのスキームとホストを引き継ぐ場面があるので受け取る。
182///
183/// # なぜ共通化するか
184///
185/// この判定は**同じ誤りを 4 箇所で繰り返していた**。`//host/path` を
186/// `starts_with('/')` の枝に巻き込み、現在のホスト配下のパスとして
187/// 取りに行ってしまう誤りで、`js/builtins.rs`・`web_engine/render.rs` で
188/// 個別に直された後、`fetch_script_text_inner` に 4 例目が残っていた
189/// (実サイトの `//ajax.googleapis.com/.../jquery.min.js` が
190/// 自ホストの HTML を返し、それを JS として実行して構文エラーになっていた)。
191/// 判定を 1 箇所に集めて、5 例目が生まれないようにする。
192pub fn split_target(
193    url: &str,
194    current_is_https: bool,
195    current_host: &str,
196) -> (bool, String, String) {
197    fn split_host_path(rest: &str) -> (String, String) {
198        match rest.find('/') {
199            Some(idx) => (
200                String::from(rest.get(..idx).unwrap_or(rest)),
201                String::from(rest.get(idx..).unwrap_or("/")),
202            ),
203            None => (String::from(rest), String::from("/")),
204        }
205    }
206    // 素片(`#...`)は取得先に含めない。
207    let url = match url.split_once('#') {
208        Some((before, _)) => before,
209        None => url,
210    };
211    if let Some(rest) = url.strip_prefix("https://") {
212        let (h, p) = split_host_path(rest);
213        (true, h, p)
214    } else if let Some(rest) = url.strip_prefix("http://") {
215        let (h, p) = split_host_path(rest);
216        (false, h, p)
217    } else if let Some(rest) = url.strip_prefix("//") {
218        // プロトコル相対。**現在のスキームを引き継ぎ、ホストは相手側**。
219        // ここを `/` の枝に落とすのが繰り返された誤り。
220        let (h, p) = split_host_path(rest);
221        (current_is_https, h, p)
222    } else if url.starts_with('/') {
223        (current_is_https, String::from(current_host), String::from(url))
224    } else {
225        (current_is_https, String::from(current_host), String::from(url))
226    }
227}