Skip to main content

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}