rust_store_core/
resource.rs1use std::collections::BTreeMap;
7
8use serde_json::{Map, Value};
9
10pub 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
21pub 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
27pub fn ref_kind(reference: &str) -> &'static str {
29 if is_external_url(reference) { "external" } else { "internal" }
30}
31
32pub 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
97fn 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
127fn 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
140fn 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
150fn 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}