atmos/os_lib/web_engine/rel_path.rs
1//! HTML 属性の相対パス解決(純粋モジュール)。
2//!
3//! `resolve_dom_paths` が `<img src>` `<script src>` `<link href>`
4//! `<video src>` を書き換えるときに使う。
5//!
6//! # 触ってはいけない値がある
7//!
8//! この関数は「パスらしきもの」だけを組み立て直す。
9//! 【2026-08-27 バグ修正】以前は判定が
10//! 「`/` で始まる」か「`://` を含む」だけで、次の 4 種が壊れていた。
11//!
12//! - `data:image/png;base64,iVBORw0K...`
13//! base64 は `/` を**含む**。`split('/')` で分解され、
14//! `..` に見える断片で `pop()` まで走る。**画像が完全に壊れる**。
15//! インライン画像は実サイトでごく普通に使われる。
16//! - `#about`
17//! `/dir/#about` という存在しないパスになる。
18//! 同一ページ内の移動が「404」になって見える。
19//! - `mailto:` / `tel:` / `javascript:`
20//! `/dir/mailto:someone@example.com` になる。
21//! - `//cdn.example.com/lib.js`(プロトコル相対)
22//! `/` で始まるので**現在ホストの絶対パス**として扱われ、
23//! 別ホストへ取りに行かない。`url_resolve::resolve_url` は
24//! こちらを正しく処理していたので、作法が食い違っていた。
25//!
26//! # 判定の基準
27//!
28//! 最初の `/` より前に `:` があれば**スキーム付き**とみなす
29//! (`data:`、`mailto:`、`https:` など)。これは URL の一般規則で、
30//! 個別のスキーム名を並べるより漏れにくい。
31
32extern crate alloc;
33use alloc::string::String;
34
35/// 値がスキーム付き(`data:` `mailto:` `https:` など)か。
36///
37/// 最初の `/` より前に `:` があるかで判定する。
38/// `a/b:c` は真ではない(`:` が `/` の後ろなので、ただのパス)。
39pub fn has_scheme(v: &str) -> bool {
40 match v.find('/') {
41 Some(slash) => v.get(..slash).is_some_and(|head| head.contains(':')),
42 None => v.contains(':'),
43 }
44}
45
46/// そのまま渡すべき値か(組み立て直してはいけない)。
47pub fn is_opaque(v: &str) -> bool {
48 // 空はそのまま返す(組み立てるとベースだけが残ってしまう)。
49 if v.is_empty() {
50 return true;
51 }
52 // フラグメントとクエリだけの参照は現在のページを指す。
53 if v.starts_with('#') || v.starts_with('?') {
54 return true;
55 }
56 // プロトコル相対。ホストが別なので、パスとして扱ってはいけない。
57 if v.starts_with("//") {
58 return true;
59 }
60 has_scheme(v)
61}
62
63/// `base_path`(ディレクトリ。末尾 `/`)と `rel_path` を繋いで正規化する。
64///
65/// 触ってはいけない値(`is_opaque`)と、既に絶対パスのものは素通し。
66pub fn resolve_relative_path(base_path: &str, rel_path: &str) -> String {
67 if is_opaque(rel_path) {
68 return String::from(rel_path);
69 }
70 if rel_path.starts_with('/') {
71 return String::from(rel_path);
72 }
73
74 let joined = alloc::format!("{}{}", base_path, rel_path);
75 let mut parts = alloc::vec::Vec::new();
76 for part in joined.split('/') {
77 if part == "." || part.is_empty() {
78 continue;
79 } else if part == ".." {
80 parts.pop();
81 } else {
82 parts.push(part);
83 }
84 }
85
86 let mut resolved = String::from("/");
87 resolved.push_str(&parts.join("/"));
88 resolved
89}