1use serde_json::{json, Map, Value};
10
11pub const MAX_LIST_KEYS: usize = 10_000;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum Route {
17 SealStatus,
19 Health,
21 Leader,
23 MountPreflight(String),
25 ReadData { mount: String, path: String },
27 WriteData { mount: String, path: String },
29 List { mount: String, path: String },
31 ReadMetadata { mount: String, path: String },
33 Unsupported,
36 NotFound,
38}
39
40fn split_mount<'a>(path: &'a str, mounts: &[String]) -> Option<(String, &'a str)> {
42 let path = path.trim_start_matches('/');
43 mounts
44 .iter()
45 .filter(|m| {
46 let m = m.trim_matches('/');
47 path == m || path.starts_with(&format!("{m}/"))
48 })
49 .max_by_key(|m| m.len())
50 .map(|m| {
51 let m = m.trim_matches('/');
52 (m.to_string(), path[m.len()..].trim_start_matches('/'))
53 })
54}
55
56pub fn resolve(method: &str, path: &str, list_query: bool, mounts: &[String]) -> Route {
58 let Some(rest) = path.strip_prefix("/v1/") else {
59 return Route::NotFound;
60 };
61 let is_get = method == "GET" || method == "HEAD";
62 match rest.trim_end_matches('/') {
63 "sys/seal-status" if is_get => return Route::SealStatus,
64 "sys/health" if is_get => return Route::Health,
65 "sys/leader" if is_get => return Route::Leader,
66 _ => {}
67 }
68 if let Some(p) = rest.strip_prefix("sys/internal/ui/mounts/") {
69 if is_get {
70 return Route::MountPreflight(p.to_string());
71 }
72 return Route::NotFound;
73 }
74 let Some((mount, after)) = split_mount(rest, mounts) else {
75 return Route::NotFound;
76 };
77 let (kind, secret_path) = match after.split_once('/') {
78 Some((k, p)) => (k, p.trim_end_matches('/').to_string()),
79 None => (after, String::new()),
80 };
81 match (kind, method) {
82 ("data", "GET") if !secret_path.is_empty() => Route::ReadData {
83 mount,
84 path: secret_path,
85 },
86 ("data", "PUT" | "POST") if !secret_path.is_empty() => Route::WriteData {
87 mount,
88 path: secret_path,
89 },
90 ("metadata", "LIST") => Route::List {
91 mount,
92 path: secret_path,
93 },
94 ("metadata", "GET") if list_query => Route::List {
95 mount,
96 path: secret_path,
97 },
98 ("metadata", "GET") if !secret_path.is_empty() => Route::ReadMetadata {
99 mount,
100 path: secret_path,
101 },
102 _ => Route::Unsupported,
103 }
104}
105
106fn hex_id(seed: &str) -> String {
109 use std::hash::{Hash, Hasher};
110 let mut out = String::with_capacity(64);
111 for round in 0u8..4 {
112 let mut h = std::collections::hash_map::DefaultHasher::new();
113 (round, seed).hash(&mut h);
114 out.push_str(&format!("{:016x}", h.finish()));
115 }
116 out
117}
118
119pub fn errors_body(errors: &[String]) -> Value {
121 json!({ "errors": errors })
122}
123
124pub fn request_id() -> String {
126 uuid::Uuid::new_v4().to_string()
127}
128
129pub fn envelope(data: Value, mount_type: &str) -> Value {
131 json!({
132 "request_id": request_id(),
133 "lease_id": "",
134 "renewable": false,
135 "lease_duration": 0,
136 "data": data,
137 "wrap_info": null,
138 "warnings": null,
139 "auth": null,
140 "mount_type": mount_type,
141 })
142}
143
144pub fn mount_preflight(mount: &str) -> Value {
147 envelope(
148 json!({
149 "accessor": format!("kv_{}", &hex_id(mount)[..8]),
150 "config": {"default_lease_ttl": 0, "force_no_cache": false, "max_lease_ttl": 0},
151 "description": "key/value secret storage",
152 "external_entropy_access": false,
153 "local": false,
154 "options": {"version": "2"},
155 "path": format!("{mount}/"),
156 "plugin_version": "",
157 "running_plugin_version": "v0.20.0+builtin",
158 "running_sha256": "",
159 "seal_wrap": false,
160 "type": "kv",
161 "uuid": request_id(),
162 }),
163 "",
164 )
165}
166
167pub fn preflight_mount(path: &str, mounts: &[String]) -> Option<String> {
169 split_mount(path, mounts).map(|(m, _)| m)
170}
171
172#[derive(Clone, Debug)]
174pub struct VaultIdentity {
175 pub version: String,
176 pub cluster_name: String,
177}
178
179pub fn seal_status(id: &VaultIdentity) -> Value {
180 json!({
181 "type": "shamir",
182 "initialized": true,
183 "sealed": false,
184 "t": 1,
185 "n": 1,
186 "progress": 0,
187 "nonce": "",
188 "version": id.version,
189 "build_date": "2025-01-01T00:00:00Z",
190 "migration": false,
191 "cluster_name": id.cluster_name,
192 "cluster_id": hex_id(&id.cluster_name)[..36].to_string(),
193 "recovery_seal": false,
194 "storage_type": "inmem",
195 })
196}
197
198pub fn health(id: &VaultIdentity) -> Value {
199 json!({
200 "initialized": true,
201 "sealed": false,
202 "standby": false,
203 "performance_standby": false,
204 "replication_performance_mode": "disabled",
205 "replication_dr_mode": "disabled",
206 "server_time_utc": now_unix(),
207 "version": id.version,
208 "enterprise": false,
209 "cluster_name": id.cluster_name,
210 "cluster_id": hex_id(&id.cluster_name)[..36].to_string(),
211 "echo_duration_ms": 0,
212 "clock_skew_ms": 0,
213 })
214}
215
216pub fn leader() -> Value {
217 json!({
218 "ha_enabled": false,
219 "is_self": false,
220 "active_time": "0001-01-01T00:00:00Z",
221 "leader_address": "",
222 "leader_cluster_address": "",
223 "performance_standby": false,
224 "performance_standby_last_remote_wal": 0,
225 })
226}
227
228fn now_unix() -> i64 {
229 crate::utils::clock::SystemTime::now()
230 .duration_since(crate::utils::clock::UNIX_EPOCH)
231 .map(|d| d.as_secs() as i64)
232 .unwrap_or(0)
233}
234
235fn now_rfc3339() -> String {
236 chrono::DateTime::from_timestamp(now_unix(), 0)
237 .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Micros, true))
238 .unwrap_or_default()
239}
240
241fn created_time(v: Option<&Value>) -> Result<String, String> {
243 match v {
244 None | Some(Value::Null) => Ok(now_rfc3339()),
245 Some(Value::String(s)) => chrono::DateTime::parse_from_rfc3339(s.trim())
246 .map(|t| {
247 t.with_timezone(&chrono::Utc)
248 .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
249 })
250 .map_err(|_| format!("created_time {s:?} is not RFC 3339")),
251 Some(_) => Err("created_time must be an RFC 3339 string".to_string()),
252 }
253}
254
255fn version_of(v: Option<&Value>) -> Result<u64, String> {
256 match v {
257 None | Some(Value::Null) => Ok(1),
258 Some(Value::Number(n)) => match n.as_u64() {
259 Some(0) | None => Err("version must be a positive integer".to_string()),
260 Some(n) => Ok(n),
261 },
262 Some(Value::String(s)) => s
263 .trim()
264 .parse::<u64>()
265 .ok()
266 .filter(|n| *n > 0)
267 .ok_or_else(|| format!("version {s:?} must be a positive integer")),
268 Some(_) => Err("version must be a positive integer".to_string()),
269 }
270}
271
272fn custom_metadata(v: Option<&Value>) -> Result<Value, String> {
273 match v {
274 None | Some(Value::Null) => Ok(Value::Null),
275 Some(Value::Object(m)) => {
276 let mut out = Map::new();
277 for (k, v) in m {
278 let text = match v {
279 Value::String(s) => s.clone(),
280 other => other.to_string(),
281 };
282 out.insert(k.clone(), Value::String(text));
283 }
284 Ok(Value::Object(out))
285 }
286 Some(_) => Err("custom_metadata must be an object of strings".to_string()),
287 }
288}
289
290fn version_metadata(answer: &Value) -> Result<Value, String> {
292 Ok(json!({
293 "created_time": created_time(answer.get("created_time"))?,
294 "custom_metadata": custom_metadata(answer.get("custom_metadata"))?,
295 "deletion_time": "",
296 "destroyed": false,
297 "version": version_of(answer.get("version"))?,
298 }))
299}
300
301fn secret_data(answer: &Value) -> Result<Value, String> {
304 match answer.get("data") {
305 None | Some(Value::Null) => Ok(json!({})),
306 Some(Value::Object(m)) => Ok(Value::Object(m.clone())),
307 Some(_) => Err("data must be an object of key/value pairs".to_string()),
308 }
309}
310
311pub fn render_secret(answer: &Value) -> Result<Value, String> {
313 Ok(envelope(
314 json!({"data": secret_data(answer)?, "metadata": version_metadata(answer)?}),
315 "kv",
316 ))
317}
318
319pub fn render_write_ok(answer: &Value) -> Result<Value, String> {
321 Ok(envelope(version_metadata(answer)?, "kv"))
322}
323
324pub fn render_metadata(answer: &Value) -> Result<Value, String> {
326 let meta = version_metadata(answer)?;
327 let current = meta["version"].as_u64().unwrap_or(1);
328 let created = meta["created_time"].clone();
329 let mut versions = Map::new();
330 versions.insert(
331 current.to_string(),
332 json!({"created_time": created, "deletion_time": "", "destroyed": false}),
333 );
334 Ok(envelope(
335 json!({
336 "cas_required": false,
337 "created_time": created,
338 "current_version": current,
339 "custom_metadata": meta["custom_metadata"],
340 "delete_version_after": "0s",
341 "max_versions": 0,
342 "oldest_version": 0,
343 "updated_time": created,
344 "versions": versions,
345 }),
346 "kv",
347 ))
348}
349
350pub fn render_list(answer: &Value) -> Result<Value, String> {
353 let keys = answer
354 .get("keys")
355 .and_then(Value::as_array)
356 .ok_or("keys must be an array of strings")?;
357 if keys.len() > MAX_LIST_KEYS {
358 return Err(format!("more than {MAX_LIST_KEYS} keys in one listing"));
359 }
360 let mut out = Vec::with_capacity(keys.len());
361 for k in keys {
362 let k = k.as_str().ok_or("keys must be strings")?;
363 let name = k.strip_suffix('/').unwrap_or(k);
364 if name.is_empty() || name.contains('/') || name.len() > 512 {
365 return Err(format!(
366 "key {k:?} must be one path segment, with a trailing '/' for a folder"
367 ));
368 }
369 out.push(Value::String(k.to_string()));
370 }
371 Ok(envelope(json!({"keys": out}), "kv"))
372}
373
374pub fn render_error(answer: &Value) -> Result<(u16, Vec<String>), String> {
376 let status = answer.get("status").and_then(Value::as_u64).unwrap_or(400);
377 if !(400..600).contains(&status) {
378 return Err(format!(
379 "status must be a 4xx or 5xx HTTP status, got {status}"
380 ));
381 }
382 let errors = match answer.get("errors") {
383 None | Some(Value::Null) => Vec::new(),
384 Some(Value::Array(a)) => a
385 .iter()
386 .map(|e| match e {
387 Value::String(s) => crate::utils::sanitize::line_field(s),
388 other => other.to_string(),
389 })
390 .collect(),
391 Some(Value::String(s)) => vec![crate::utils::sanitize::line_field(s)],
392 Some(_) => return Err("errors must be an array of strings".to_string()),
393 };
394 Ok((status as u16, errors))
395}