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) { "external" } else { "internal" }
30}
31
32/// URL 纯组合(无 IO)。cfg:
33///   {
34///     "baseUrl": "https://cdn.example.com",
35///     "pathTemplate": "/{contentPath}",          // 默认 "{key}";可用 {key}/{sha1}/{contentPath}
36///     "query": { "x-oss-process": "image/resize,w_{w}" },  // 值内可含 {var}
37///     "vars": { "w": "200", "h": "100" },        // 供模板与 query 值替换
38///     "externalPassthrough": true                 // 默认 true;false 时遇外部 URL 显式 Err
39///   }
40///
41/// 规则:
42///   1. reference 为空 → Err;
43///   2. 外部 URL:externalPassthrough(默认 true)→ 原样返回;显式 false → Err;
44///   3. 内部 id:baseUrl 与渲染后的 path 拼接(缺斜杠自动补一个),query 按键名排序追加并 percent-encode。
45pub fn compose_url(reference: &str, cfg: &Value) -> Result<String, String> {
46    if reference.is_empty() {
47        return Err("resource reference 为空".to_string());
48    }
49    let obj = cfg.as_object();
50    if is_external_url(reference) {
51        let passthrough = obj
52            .and_then(|o| o.get("externalPassthrough"))
53            .and_then(|v| v.as_bool())
54            .unwrap_or(true);
55        return if passthrough {
56            Ok(reference.to_string())
57        } else {
58            Err(format!("外部 URL 直返被禁用: \"{reference}\""))
59        };
60    }
61    let empty = Map::new();
62    let o = obj.unwrap_or(&empty);
63    let base = o.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("");
64    let template = o
65        .get("pathTemplate")
66        .and_then(|v| v.as_str())
67        .unwrap_or("{key}");
68    let mut vars: BTreeMap<String, String> = BTreeMap::new();
69    vars.insert("key".into(), reference.to_string());
70    vars.insert("sha1".into(), reference.to_string());
71    vars.insert("contentPath".into(), content_path(reference)?);
72    if let Some(Value::Object(m)) = o.get("vars") {
73        for (k, v) in m {
74            vars.insert(k.clone(), scalar_to_string(v));
75        }
76    }
77    let path = render(template, &vars);
78    let mut url = join_base(base, &path);
79    if let Some(Value::Object(q)) = o.get("query") {
80        let mut pairs: BTreeMap<String, String> = BTreeMap::new();
81        for (k, v) in q {
82            pairs.insert(k.clone(), percent_encode(&render(&scalar_to_string(v), &vars)));
83        }
84        if !pairs.is_empty() {
85            let qs = pairs
86                .iter()
87                .map(|(k, v)| format!("{}={}", percent_encode(k), v))
88                .collect::<Vec<_>>()
89                .join("&");
90            url.push('?');
91            url.push_str(&qs);
92        }
93    }
94    Ok(url)
95}
96
97/// 模板渲染:把 `{name}` 用 vars 替换;未知名原样保留(显式,不静默删)。
98fn render(template: &str, vars: &BTreeMap<String, String>) -> String {
99    let mut out = String::with_capacity(template.len());
100    let mut rest = template;
101    while let Some(open) = rest.find('{') {
102        out.push_str(&rest[..open]);
103        let after = &rest[open + 1..];
104        match after.find('}') {
105            Some(close) => {
106                let name = &after[..close];
107                match vars.get(name) {
108                    Some(v) => out.push_str(v),
109                    None => {
110                        out.push('{');
111                        out.push_str(name);
112                        out.push('}');
113                    }
114                }
115                rest = &after[close + 1..];
116            }
117            None => {
118                out.push_str(&rest[open..]);
119                rest = "";
120            }
121        }
122    }
123    out.push_str(rest);
124    out
125}
126
127/// base 与 path 拼接(正确处理斜杠边界;base 为空则直接返回 path)。
128fn join_base(base: &str, path: &str) -> String {
129    if base.is_empty() {
130        return path.to_string();
131    }
132    let b = base.trim_end_matches('/');
133    if path.starts_with('/') {
134        format!("{b}{path}")
135    } else {
136        format!("{b}/{path}")
137    }
138}
139
140/// 标量 → 字符串(字符串原样;数字/布尔用 JSON 字面;其余空串)。
141fn scalar_to_string(v: &Value) -> String {
142    match v {
143        Value::String(s) => s.clone(),
144        Value::Number(n) => n.to_string(),
145        Value::Bool(b) => b.to_string(),
146        _ => String::new(),
147    }
148}
149
150/// RFC3986 unreserved 之外全部 percent-encode(UTF-8 逐字节)。
151fn percent_encode(s: &str) -> String {
152    let mut out = String::with_capacity(s.len());
153    for b in s.bytes() {
154        let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
155        if unreserved {
156            out.push(b as char);
157        } else {
158            out.push_str(&format!("%{b:02X}"));
159        }
160    }
161    out
162}