1use std::collections::BTreeMap;
5use std::sync::Arc;
6
7use serde::Deserialize;
8use serde::de::DeserializeOwned;
9use serde_json::{Value, json};
10
11use crate::error::{Error, Result};
12use crate::org::OrgId;
13use crate::secrets::Secrets;
14use crate::server::{Caller, Registry, Tool};
15use crate::stack::secrets::Cycle;
16
17type Handler = Box<dyn Fn(&Secrets, Value, &Caller) -> Result<Value> + Send + Sync>;
18
19pub type InUse = Arc<dyn Fn(&OrgId, &str) -> Vec<String> + Send + Sync>;
21
22#[derive(Debug, Clone, Default)]
25pub struct Outcome {
26 pub cycles: Vec<Cycle>,
27 pub skipped: Vec<Skipped>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
32pub struct Skipped {
33 pub kind: String,
35 pub name: String,
36 pub reason: String,
37}
38
39impl Outcome {
40 fn fill(&self, v: &mut Value) {
42 v["rolled"] = json!(crate::stack::secrets::cycled_stacks(&self.cycles));
43 v["services"] = json!(self.cycles);
44 v["skipped"] = json!(self.skipped);
45 }
46}
47
48pub type Prepare =
52 Arc<dyn Fn(&OrgId, &str, &[u8]) -> Result<Vec<crate::stack::secrets::Applied>> + Send + Sync>;
53
54pub type Changed = Arc<dyn Fn(&OrgId, &str) -> Outcome + Send + Sync>;
57
58pub type Refresh = Arc<dyn Fn(&OrgId, &str) -> Result<(Vec<(String, u64)>, Outcome)> + Send + Sync>;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Binding {
65 pub name: String,
67 pub driver: String,
68 pub version: u64,
69 pub stack: String,
70}
71
72pub type Bindings = Arc<dyn Fn(&OrgId) -> Vec<Binding> + Send + Sync>;
74
75#[derive(Clone)]
77pub struct Hooks {
78 pub prepare: Prepare,
79 pub in_use: InUse,
80 pub changed: Changed,
81 pub refresh: Refresh,
82 pub bindings: Bindings,
83}
84
85impl Hooks {
86 pub fn none() -> Hooks {
88 Hooks {
89 prepare: Arc::new(|_, _, _| Ok(Vec::new())),
90 in_use: Arc::new(|_, _| Vec::new()),
91 changed: Arc::new(|_, _| Outcome::default()),
92 refresh: Arc::new(|_, _| Ok((Vec::new(), Outcome::default()))),
93 bindings: Arc::new(|_| Vec::new()),
94 }
95 }
96}
97
98fn args<T: DeserializeOwned>(v: Value) -> Result<T> {
99 serde_json::from_value(v).map_err(|e| Error::invalid(format!("bad arguments: {e}")))
100}
101
102fn org_for(c: &Caller, org: Option<&str>) -> Result<OrgId> {
104 let org = match org {
105 Some(o) => OrgId::new(o)?,
106 None => OrgId::default_org(),
107 };
108 let _ = c.is_trusted();
111 Ok(org)
112}
113
114#[derive(Deserialize)]
115#[serde(deny_unknown_fields)]
116struct Named {
117 #[serde(default)]
118 org: Option<String>,
119 name: String,
120}
121
122#[derive(Deserialize)]
123#[serde(deny_unknown_fields)]
124struct OrgOnly {
125 #[serde(default)]
126 org: Option<String>,
127}
128
129fn props(extra: Value) -> Value {
130 let mut p = json!({
131 "org": {"type": "string", "description": "The org (default: default)."},
132 });
133 if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
134 p.extend(e.clone());
135 }
136 p
137}
138
139fn obj(props: Value, required: &[&str]) -> Value {
140 json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
141}
142
143fn name_prop() -> Value {
144 json!({"name": {"type": "string", "description": "Secret name: 1-128 of [A-Za-z0-9_.-], not starting with '.'."}})
145}
146
147fn value_of(b64: &str) -> Result<Vec<u8>> {
148 crate::rpc::b64_decode(b64).map_err(|_| Error::invalid("value must be base64"))
149}
150
151fn listing(stored: Vec<crate::secrets::SecretMeta>, used: Vec<Binding>) -> Result<Value> {
154 let stacks_of = |name: &str| -> Vec<String> {
155 let mut v: Vec<String> = used
156 .iter()
157 .filter(|b| b.name == name)
158 .map(|b| b.stack.clone())
159 .collect();
160 v.sort();
161 v.dedup();
162 v
163 };
164 let mut secrets = Vec::new();
165 for m in &stored {
166 let mut v = serde_json::to_value(m)?;
167 v["used_by"] = json!(stacks_of(&m.name));
168 secrets.push(v);
169 }
170 let mut refs: BTreeMap<&str, Value> = BTreeMap::new();
171 for b in used
172 .iter()
173 .filter(|b| !stored.iter().any(|m| m.name == b.name))
174 {
175 refs.entry(b.name.as_str()).or_insert_with(|| {
176 json!({"name": b.name, "driver": b.driver, "version": b.version, "used_by": stacks_of(&b.name)})
177 });
178 }
179 Ok(json!({"secrets": secrets, "references": refs.into_values().collect::<Vec<_>>()}))
180}
181
182#[expect(
184 clippy::too_many_lines,
185 reason = "predates the lint ratchet; split it when next changed"
186)]
187pub fn register(r: &mut Registry, secrets: Arc<Secrets>, hooks: Hooks) -> Result<()> {
188 let Hooks {
189 prepare,
190 in_use,
191 changed,
192 refresh,
193 bindings,
194 } = hooks;
195 let ro = json!({"readOnlyHint": true, "openWorldHint": false});
196 let destructive = json!({"destructiveHint": true, "openWorldHint": false});
197 let write = json!({"destructiveHint": false, "openWorldHint": false});
198 let value_prop = json!({"value": {"type": "string", "description": "The value, base64."}});
199
200 let mut add = |name: &str,
201 title: &str,
202 desc: &str,
203 schema: Value,
204 ann: &Value,
205 f: Handler|
206 -> Result<()> {
207 let s = secrets.clone();
208 r.register(
209 Tool::new(name, desc, schema, move |a, c| f(&s, a, c))
210 .title(title)
211 .annotations(ann.clone()),
212 )
213 };
214
215 add(
216 "secret_create",
217 "Create a secret",
218 "Create a secret in an org's store (fails if one by that name exists). The value is base64; it is encrypted at rest and never listed.",
219 obj(
220 props(json!({
221 "name": name_prop()["name"],
222 "value": value_prop["value"],
223 "driver": {"type": "string", "description": "Where it is stored (default: local)."},
224 "labels": {"type": "object", "additionalProperties": {"type": "string"}}
225 })),
226 &["name", "value"],
227 ),
228 &write,
229 Box::new(|s, a, c| {
230 #[derive(Deserialize)]
231 #[serde(deny_unknown_fields)]
232 struct A {
233 #[serde(default)]
234 org: Option<String>,
235 name: String,
236 value: String,
237 #[serde(default)]
238 driver: Option<String>,
239 #[serde(default)]
240 labels: BTreeMap<String, String>,
241 }
242 let a: A = args(a)?;
243 let org = org_for(c, a.org.as_deref())?;
244 let m = s.create(
245 &org,
246 &a.name,
247 a.driver.as_deref(),
248 &value_of(&a.value)?,
249 &a.labels,
250 )?;
251 Ok(serde_json::to_value(m)?)
252 }),
253 )?;
254 let on_set = changed.clone();
255 add(
256 "secret_set",
257 "Set a secret",
258 "Give a secret a new value (base64), bumping its version; creates it in the local store if missing. Each stack service using it acts per its `on_change`: `roll` (default; a rolling update), `restart` (in place) or `none` (files updated, replicas stale until they next start). `services` lists what each service did, `rolled` the stacks that roll or restart, `skipped` what was not cycled and why (workspaces get the file, never a restart). A stack secret with a `rotate` command (a database app's password) is first changed where the old value is kept, in a running replica (`applied`); if that fails, nothing is stored. A database app's URL secret follows its password.",
259 obj(
260 props(json!({"name": name_prop()["name"], "value": value_prop["value"]})),
261 &["name", "value"],
262 ),
263 &write,
264 Box::new(move |s, a, c| {
265 #[derive(Deserialize)]
266 #[serde(deny_unknown_fields)]
267 struct A {
268 #[serde(default)]
269 org: Option<String>,
270 name: String,
271 value: String,
272 }
273 let a: A = args(a)?;
274 let org = org_for(c, a.org.as_deref())?;
275 let value = value_of(&a.value)?;
276 let applied = prepare(&org, &a.name, &value)?;
277 let m = s.set(&org, &a.name, &value)?;
278 let outcome = on_set(&org, &a.name);
279 let mut v = serde_json::to_value(m)?;
280 outcome.fill(&mut v);
281 v["applied"] = json!(applied);
282 Ok(v)
283 }),
284 )?;
285 add(
286 "secret_get",
287 "Read a secret",
288 "A secret's value (base64, in `value`) and metadata.",
289 obj(props(name_prop()), &["name"]),
290 &ro,
291 Box::new(|s, a, c| {
292 let a: Named = args(a)?;
293 let org = org_for(c, a.org.as_deref())?;
294 let (v, m) = s.get(&org, &a.name)?;
295 Ok(json!({"meta": m, "value": crate::rpc::b64_encode(&v)}))
296 }),
297 )?;
298 add(
299 "secret_list",
300 "List secrets",
301 "An org's secrets: name, driver, version, timestamps, labels, and the deployed stacks using each (`used_by`). Never values. `references` lists the driver references (such as 1Password's vault/item/field) stacks use, which live outside the store.",
302 obj(props(json!({})), &[]),
303 &ro,
304 Box::new(move |s, a, c| {
305 let a: OrgOnly = args(a)?;
306 let org = org_for(c, a.org.as_deref())?;
307 listing(s.list(&org)?, bindings(&org))
308 }),
309 )?;
310 add(
311 "secret_inspect",
312 "Inspect a secret",
313 "One secret's metadata (never its value).",
314 obj(props(name_prop()), &["name"]),
315 &ro,
316 Box::new(|s, a, c| {
317 let a: Named = args(a)?;
318 let org = org_for(c, a.org.as_deref())?;
319 Ok(serde_json::to_value(s.inspect(&org, &a.name)?)?)
320 }),
321 )?;
322 let used = in_use;
323 add(
324 "secret_delete",
325 "Delete a secret",
326 "Delete a secret. Refused while a deployed stack's services use it.",
327 obj(props(name_prop()), &["name"]),
328 &destructive,
329 Box::new(move |s, a, c| {
330 let a: Named = args(a)?;
331 let org = org_for(c, a.org.as_deref())?;
332 s.inspect(&org, &a.name)?;
333 let stacks = used(&org, &a.name);
334 if !stacks.is_empty() {
335 return Err(Error::invalid(format!(
336 "secret {} is in use by stack {}; remove it from the stack first",
337 a.name,
338 stacks.join(", ")
339 )));
340 }
341 s.delete(&org, &a.name)?;
342 Ok(json!({"ok": true}))
343 }),
344 )?;
345 add(
346 "secret_refresh",
347 "Refresh a secret",
348 "Re-read an externally stored secret from its source now; if its version moved, each stack service using it acts per its `on_change` (`services`, `rolled`, `skipped` as for secret_set). `name` is a store name, or a stack's driver reference. A no-op for the local store.",
349 obj(
350 props(
351 json!({"name": {"type": "string", "description": "A store name, or a driver reference a stack uses."}}),
352 ),
353 &["name"],
354 ),
355 &write,
356 Box::new(move |s, a, c| {
357 let a: Named = args(a)?;
358 let org = org_for(c, a.org.as_deref())?;
359 let meta = if crate::secrets::validate_name(&a.name).is_ok() {
362 match s.refresh(&org, &a.name) {
363 Ok(m) => Some(m),
364 Err(Error::NotFound(_)) => None,
365 Err(e) => return Err(e),
366 }
367 } else {
368 None
369 };
370 let (refs, outcome) = refresh(&org, &a.name)?;
371 let mut v = match (meta, refs.first()) {
372 (Some(m), _) => serde_json::to_value(m)?,
373 (None, Some((driver, version))) => {
374 json!({"org": org, "name": a.name, "driver": driver, "version": version})
375 }
376 (None, None) => return Err(crate::secrets::not_found(&org, &a.name)),
377 };
378 outcome.fill(&mut v);
379 Ok(v)
380 }),
381 )?;
382 add(
383 "secret_resolve",
384 "Resolve compose secrets",
385 "Local callers only (`isb up`): the values (base64) of a compose file's store-backed secrets (`external`, `age`, `driver`), read from the org's store and decrypted with the daemon's key.",
386 obj(
387 props(
388 json!({"secrets": {"type": "object", "description": "Top-level compose secrets, by key."}}),
389 ),
390 &["secrets"],
391 ),
392 &ro,
393 Box::new(|s, a, c| {
394 #[derive(Deserialize)]
395 #[serde(deny_unknown_fields)]
396 struct A {
397 #[serde(default)]
398 org: Option<String>,
399 secrets: BTreeMap<String, crate::spec::SecretDef>,
400 }
401 if !c.is_trusted() {
405 return Err(Error::invalid(
406 "secret_resolve is for superadmins (the local socket: isb up) only",
407 ));
408 }
409 let a: A = args(a)?;
410 let org = org_for(c, a.org.as_deref())?;
411 let values = crate::stack::secrets::resolve(s, &org, &a.secrets)?;
412 let out: BTreeMap<String, String> = values
413 .iter()
414 .map(|(k, v)| (k.clone(), crate::rpc::b64_encode(v)))
415 .collect();
416 Ok(json!({"values": out}))
417 }),
418 )?;
419 add(
420 "secret_reencrypt",
421 "Re-encrypt secrets",
422 "Re-encrypt every stored value in the org (or every org, with all=true) to the current recipients: the daemon's key and the break-glass recipients. Run after changing the recipients.",
423 obj(
424 props(json!({"all": {"type": "boolean", "description": "Every org."}})),
425 &[],
426 ),
427 &write,
428 Box::new(|s, a, c| {
429 #[derive(Deserialize)]
430 #[serde(deny_unknown_fields)]
431 struct A {
432 #[serde(default)]
433 org: Option<String>,
434 #[serde(default)]
435 all: bool,
436 }
437 let a: A = args(a)?;
438 if a.all && a.org.is_some() {
439 return Err(Error::invalid("pass org or all, not both"));
440 }
441 let n = if a.all {
443 s.reencrypt(None)?
444 } else {
445 s.reencrypt(Some(&org_for(c, a.org.as_deref())?))?
446 };
447 Ok(json!({"reencrypted": n, "recipients": s.recipients()}))
448 }),
449 )?;
450 add(
451 "secret_recipients",
452 "Secret recipients",
453 "The age recipients values are encrypted to (public keys only): the daemon's own key first, then the break-glass recipients. `isb secret encrypt` encrypts compose `age:` values to these.",
454 obj(props(json!({})), &[]),
455 &ro,
456 Box::new(|s, a, c| {
457 let a: OrgOnly = args(a)?;
458 org_for(c, a.org.as_deref())?;
459 Ok(json!({
460 "public_key": s.keyring().public_key(),
461 "recipients": s.recipients(),
462 }))
463 }),
464 )?;
465 Ok(())
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::secrets::{Keyring, LocalDriver};
472
473 fn registry(dir: &std::path::Path) -> Registry {
474 let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
475 let s = Arc::new(Secrets::new(LocalDriver::new(dir, Arc::new(k))));
476 let in_use: InUse = Arc::new(|org: &OrgId, name: &str| {
477 if org.is_default() && name == "used" {
478 vec!["app".to_string()]
479 } else {
480 vec![]
481 }
482 });
483 let cycle = |stack: &str, service: &str, action| Cycle {
484 stack: stack.into(),
485 service: service.into(),
486 key: "k".into(),
487 secret: "used".into(),
488 from: 1,
489 to: 2,
490 action,
491 error: None,
492 };
493 let changed: Changed = Arc::new(move |org: &OrgId, name: &str| {
494 if org.is_default() && name == "used" {
495 Outcome {
496 cycles: vec![
497 cycle("app", "web", crate::spec::OnChange::Restart),
498 cycle("app", "api", crate::spec::OnChange::Roll),
499 cycle("other", "cron", crate::spec::OnChange::None),
500 ],
501 skipped: vec![Skipped {
502 kind: "workspace".into(),
503 name: "main".into(),
504 reason: "delivered".into(),
505 }],
506 }
507 } else {
508 Outcome::default()
509 }
510 });
511 let refresh: Refresh = Arc::new(move |_org: &OrgId, name: &str| {
512 if name == "op://v/item" {
513 Ok((
514 vec![("vault".to_string(), 4)],
515 Outcome {
516 cycles: vec![cycle("app", "web", crate::spec::OnChange::Roll)],
517 skipped: vec![],
518 },
519 ))
520 } else {
521 Ok((vec![], Outcome::default()))
522 }
523 });
524 let bindings: Bindings = Arc::new(|org: &OrgId| {
525 let b = |name: &str, driver: &str, stack: &str| Binding {
526 name: name.into(),
527 driver: driver.into(),
528 version: 3,
529 stack: stack.into(),
530 };
531 if org.is_default() {
532 vec![
533 b("used", "local", "web"),
534 b("used", "local", "app"),
535 b("used", "local", "app"),
536 b("vault/item/field", "onepassword", "app"),
537 ]
538 } else {
539 vec![]
540 }
541 });
542 let mut r = Registry::new();
543 register(
544 &mut r,
545 s,
546 Hooks {
547 prepare: Arc::new(|_, name: &str, v: &[u8]| {
548 if name == "locked" && v == b"bad" {
549 return Err(Error::invalid("rotate failed"));
550 }
551 Ok(vec![])
552 }),
553 in_use,
554 changed,
555 refresh,
556 bindings,
557 },
558 )
559 .unwrap();
560 r
561 }
562
563 #[test]
564 fn set_and_refresh_roll_and_resolve_is_local_only() {
565 let dir = tempfile::tempdir().unwrap();
566 let r = registry(dir.path());
567 let v = crate::rpc::b64_encode(b"one");
568 call(&r, "secret_create", json!({"name": "used", "value": v})).unwrap();
569 let m = call(&r, "secret_set", json!({"name": "used", "value": v})).unwrap();
570 assert_eq!(m["rolled"], json!(["app"]));
573 assert_eq!(m["version"], 2);
574 let actions: Vec<(String, String)> = m["services"]
575 .as_array()
576 .unwrap()
577 .iter()
578 .map(|c| {
579 (
580 format!(
581 "{}/{}",
582 c["stack"].as_str().unwrap(),
583 c["service"].as_str().unwrap()
584 ),
585 c["action"].as_str().unwrap().to_string(),
586 )
587 })
588 .collect();
589 assert_eq!(
590 actions,
591 [
592 ("app/web".to_string(), "restart".to_string()),
593 ("app/api".to_string(), "roll".to_string()),
594 ("other/cron".to_string(), "none".to_string()),
595 ]
596 );
597 assert_eq!(m["services"][0]["from"], 1);
598 assert_eq!(m["services"][0]["to"], 2);
599 assert_eq!(m["skipped"][0]["kind"], "workspace");
600 let m = call(&r, "secret_refresh", json!({"name": "op://v/item"})).unwrap();
602 assert_eq!(
603 (
604 m["driver"].as_str(),
605 m["version"].as_u64(),
606 m["rolled"].clone()
607 ),
608 (Some("vault"), Some(4), json!(["app"]))
609 );
610 assert!(matches!(
611 call(&r, "secret_refresh", json!({"name": "op://v/other"})),
612 Err(Error::NotFound(_))
613 ));
614 let m = call(&r, "secret_refresh", json!({"name": "used"})).unwrap();
615 assert_eq!(m["version"], 2);
616 let res = call(
618 &r,
619 "secret_resolve",
620 json!({"secrets": {"a": {"external": true, "name": "used"}, "f": {"file": "./x"}}}),
621 )
622 .unwrap();
623 assert_eq!(res["values"]["a"], json!(v));
624 assert!(res["values"].get("f").is_none());
625 let remote = Caller::Unauthenticated {
627 addr: "127.0.0.1:1".parse().unwrap(),
628 };
629 let e = (r.get("secret_resolve").unwrap().handler)(
630 json!({"secrets": {"a": {"external": true, "name": "used"}}}),
631 &remote,
632 )
633 .unwrap_err();
634 assert!(e.to_string().contains("superadmins"), "{e}");
635 }
636
637 #[test]
638 fn list_says_who_uses_what() {
639 let dir = tempfile::tempdir().unwrap();
640 let r = registry(dir.path());
641 let v = crate::rpc::b64_encode(b"x");
642 for n in ["used", "idle"] {
643 call(&r, "secret_create", json!({"name": n, "value": v})).unwrap();
644 }
645 let l = call(&r, "secret_list", json!({})).unwrap();
646 let by = |n: &str| {
647 l["secrets"]
648 .as_array()
649 .unwrap()
650 .iter()
651 .find(|s| s["name"] == n)
652 .unwrap()["used_by"]
653 .clone()
654 };
655 assert_eq!(by("used"), json!(["app", "web"]));
656 assert_eq!(by("idle"), json!([]));
657 assert_eq!(
658 l["references"],
659 json!([{"name": "vault/item/field", "driver": "onepassword", "version": 3, "used_by": ["app"]}])
660 );
661 let l = call(&r, "secret_list", json!({"org": "norm"})).unwrap();
662 assert_eq!(l["references"], json!([]));
663 }
664
665 fn call(r: &Registry, tool: &str, a: Value) -> Result<Value> {
666 let local = Caller::Local { uid: None };
667 (r.get(tool).unwrap().handler)(a, &local)
668 }
669
670 #[test]
671 fn tools_round_trip() {
672 let dir = tempfile::tempdir().unwrap();
673 let r = registry(dir.path());
674 let v = crate::rpc::b64_encode(b"pa\x00ss");
675 let m = call(
676 &r,
677 "secret_create",
678 json!({"name": "db", "value": v, "labels": {"a": "b"}}),
679 )
680 .unwrap();
681 assert_eq!(
682 (m["version"].as_u64(), m["org"].as_str()),
683 (Some(1), Some("default"))
684 );
685 assert!(call(&r, "secret_create", json!({"name": "db", "value": v})).is_err());
686 let g = call(&r, "secret_get", json!({"name": "db"})).unwrap();
687 assert_eq!(
688 crate::rpc::b64_decode(g["value"].as_str().unwrap()).unwrap(),
689 b"pa\x00ss"
690 );
691 let m = call(
692 &r,
693 "secret_set",
694 json!({"name": "db", "value": crate::rpc::b64_encode(b"new")}),
695 )
696 .unwrap();
697 assert_eq!(m["version"], 2);
698 assert_eq!(m["labels"]["a"], "b");
699 call(
701 &r,
702 "secret_create",
703 json!({"org": "norm", "name": "db", "value": v}),
704 )
705 .unwrap();
706 let l = call(&r, "secret_list", json!({"org": "norm"})).unwrap();
707 assert_eq!(l["secrets"].as_array().unwrap().len(), 1);
708 assert!(call(&r, "secret_list", json!({"org": "Bad Org"})).is_err());
709 let l = call(&r, "secret_list", json!({})).unwrap();
711 let i = call(&r, "secret_inspect", json!({"name": "db"})).unwrap();
712 for v in [&l, &i] {
713 let t = v.to_string();
714 assert!(!t.contains("value") && !t.contains("bmV3"), "{t}");
715 }
716 assert_eq!(i["version"], 2);
717 assert_eq!(
718 call(&r, "secret_refresh", json!({"name": "db"})).unwrap()["version"],
719 2
720 );
721 let re = call(&r, "secret_reencrypt", json!({"all": true})).unwrap();
722 assert_eq!(re["reencrypted"], 2);
723 let re = call(&r, "secret_reencrypt", json!({"org": "norm"})).unwrap();
724 assert_eq!(re["reencrypted"], 1);
725 assert!(call(&r, "secret_reencrypt", json!({"org": "norm", "all": true})).is_err());
726 let rc = call(&r, "secret_recipients", json!({})).unwrap();
727 assert_eq!(rc["recipients"][0], rc["public_key"]);
728 call(&r, "secret_create", json!({"name": "used", "value": v})).unwrap();
730 let e = call(&r, "secret_delete", json!({"name": "used"})).unwrap_err();
731 assert!(e.to_string().contains("in use by stack app"), "{e}");
732 call(&r, "secret_delete", json!({"name": "db"})).unwrap();
733 assert!(matches!(
734 call(&r, "secret_delete", json!({"name": "db"})),
735 Err(Error::NotFound(_))
736 ));
737 call(&r, "secret_create", json!({"name": "locked", "value": v})).unwrap();
739 let bad = crate::rpc::b64_encode(b"bad");
740 assert!(call(&r, "secret_set", json!({"name": "locked", "value": bad})).is_err());
741 assert_eq!(
742 call(&r, "secret_inspect", json!({"name": "locked"})).unwrap()["version"],
743 1
744 );
745 assert!(call(&r, "secret_set", json!({"name": "x", "value": "!!"})).is_err());
747 assert!(call(&r, "secret_get", json!({"name": "x", "extra": 1})).is_err());
748 assert!(call(&r, "secret_get", json!({"name": "../etc"})).is_err());
749 }
750}