Skip to main content

rust_store_core/
resource.rs

1//! 资源纯语义:内容寻址路径 + 引用归一 + URL 模板组合。
2//!
3//! 铁律:本模块**无 IO / 无时钟 / 无随机 / 无密钥**——只做确定性字符串与路径运算。
4//! 签名参数(依赖时钟与密钥)由宿主注入,不在此实现。
5
6use std::collections::BTreeMap;
7
8use serde_json::{Map, Value};
9
10/// 内容寻址路径:`objects/{sha1[0..2]}/{sha1}`。
11///
12/// `sha1` 为空或不足 2 字符 → Err(显式拒绝,禁静默产出半截路径)。
13pub fn content_path(sha1: &str) -> Result<String, String> {
14    if sha1.len() < 2 {
15        return Err(format!("sha1 非法(长度 < 2): \"{sha1}\""));
16    }
17    let shard = &sha1[..2];
18    Ok(format!("objects/{shard}/{sha1}"))
19}
20
21/// 引用是否为外部 URL:`http://` / `https://` / `data:` 前缀(大小写不敏感)。
22pub fn is_external_url(reference: &str) -> bool {
23    let r = reference.trim().to_ascii_lowercase();
24    r.starts_with("http://") || r.starts_with("https://") || r.starts_with("data:")
25}
26
27/// 引用类型:`"external"` | `"internal"`。
28pub fn ref_kind(reference: &str) -> &'static str {
29    if is_external_url(reference) {
30        "external"
31    } else {
32        "internal"
33    }
34}
35
36/// URL 纯组合(无 IO)。cfg:
37///   {
38///     "baseUrl": "https://cdn.example.com",
39///     "pathTemplate": "/{contentPath}",          // 默认 "{key}";可用 {key}/{sha1}/{contentPath}
40///     "query": { "x-oss-process": "image/resize,w_{w}" },  // 值内可含 {var}
41///     "vars": { "w": "200", "h": "100" },        // 供模板与 query 值替换
42///     "externalPassthrough": true                 // 默认 true;false 时遇外部 URL 显式 Err
43///   }
44///
45/// 规则:
46///   1. reference 为空 → Err;
47///   2. 外部 URL:externalPassthrough(默认 true)→ 原样返回;显式 false → Err;
48///   3. 内部 id:baseUrl 与渲染后的 path 拼接(缺斜杠自动补一个),query 按键名排序追加并 percent-encode。
49pub fn compose_url(reference: &str, cfg: &Value) -> Result<String, String> {
50    if reference.is_empty() {
51        return Err("resource reference 为空".to_string());
52    }
53    let obj = cfg.as_object();
54    if is_external_url(reference) {
55        let passthrough = obj
56            .and_then(|o| o.get("externalPassthrough"))
57            .and_then(|v| v.as_bool())
58            .unwrap_or(true);
59        return if passthrough {
60            Ok(reference.to_string())
61        } else {
62            Err(format!("外部 URL 直返被禁用: \"{reference}\""))
63        };
64    }
65    let empty = Map::new();
66    let o = obj.unwrap_or(&empty);
67    let base = o.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("");
68    let template = o
69        .get("pathTemplate")
70        .and_then(|v| v.as_str())
71        .unwrap_or("{key}");
72    let mut vars: BTreeMap<String, String> = BTreeMap::new();
73    vars.insert("key".into(), reference.to_string());
74    vars.insert("sha1".into(), reference.to_string());
75    vars.insert("contentPath".into(), content_path(reference)?);
76    if let Some(Value::Object(m)) = o.get("vars") {
77        for (k, v) in m {
78            vars.insert(k.clone(), scalar_to_string(v));
79        }
80    }
81    let path = render(template, &vars);
82    let mut url = join_base(base, &path);
83    if let Some(Value::Object(q)) = o.get("query") {
84        let mut pairs: BTreeMap<String, String> = BTreeMap::new();
85        for (k, v) in q {
86            pairs.insert(
87                k.clone(),
88                percent_encode(&render(&scalar_to_string(v), &vars)),
89            );
90        }
91        if !pairs.is_empty() {
92            let qs = pairs
93                .iter()
94                .map(|(k, v)| format!("{}={}", percent_encode(k), v))
95                .collect::<Vec<_>>()
96                .join("&");
97            url.push('?');
98            url.push_str(&qs);
99        }
100    }
101    Ok(url)
102}
103
104/// 模板渲染:把 `{name}` 用 vars 替换;未知名原样保留(显式,不静默删)。
105fn render(template: &str, vars: &BTreeMap<String, String>) -> String {
106    let mut out = String::with_capacity(template.len());
107    let mut rest = template;
108    while let Some(open) = rest.find('{') {
109        out.push_str(&rest[..open]);
110        let after = &rest[open + 1..];
111        match after.find('}') {
112            Some(close) => {
113                let name = &after[..close];
114                match vars.get(name) {
115                    Some(v) => out.push_str(v),
116                    None => {
117                        out.push('{');
118                        out.push_str(name);
119                        out.push('}');
120                    }
121                }
122                rest = &after[close + 1..];
123            }
124            None => {
125                out.push_str(&rest[open..]);
126                rest = "";
127            }
128        }
129    }
130    out.push_str(rest);
131    out
132}
133
134/// base 与 path 拼接(正确处理斜杠边界;base 为空则直接返回 path)。
135fn join_base(base: &str, path: &str) -> String {
136    if base.is_empty() {
137        return path.to_string();
138    }
139    let b = base.trim_end_matches('/');
140    if path.starts_with('/') {
141        format!("{b}{path}")
142    } else {
143        format!("{b}/{path}")
144    }
145}
146
147/// 标量 → 字符串(字符串原样;数字/布尔用 JSON 字面;其余空串)。
148fn scalar_to_string(v: &Value) -> String {
149    match v {
150        Value::String(s) => s.clone(),
151        Value::Number(n) => n.to_string(),
152        Value::Bool(b) => b.to_string(),
153        _ => String::new(),
154    }
155}
156
157/// RFC3986 unreserved 之外全部 percent-encode(UTF-8 逐字节)。
158fn percent_encode(s: &str) -> String {
159    let mut out = String::with_capacity(s.len());
160    for b in s.bytes() {
161        let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
162        if unreserved {
163            out.push(b as char);
164        } else {
165            out.push_str(&format!("%{b:02X}"));
166        }
167    }
168    out
169}