1use std::collections::BTreeSet;
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use serde_json::{Value, json};
22
23use super::{Remove, ready, remaining, stream_lines, uplink_network};
24use crate::client::{Client, encode_segment};
25use crate::error::{Error, Result};
26use crate::exec::ExecOptions;
27use crate::sandbox::Sandbox;
28
29pub const DEFAULT_RECIPE: &str = include_str!("workspace-image.sh");
31pub const DEFAULT_NAME: &str = "isb-workspace";
33pub const DEFAULT_BASE: &str = "images:ubuntu/24.04";
35pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30 * 60);
37pub const MAX_TIMEOUT: Duration = Duration::from_secs(2 * 3600);
38pub const MAX_RECIPE: usize = 256 * 1024;
40
41pub const PROP_MARK: &str = "isb.workspace-image";
43pub const PROP_RECIPE: &str = "isb.recipe-sha256";
44pub const PROP_BASE: &str = "isb.base";
45pub const PROP_BUILT_BY: &str = "isb.built-by";
46pub const PROP_BUILT_AT: &str = "isb.built-at";
47
48const RECIPE_PATH: &str = "/root/isb-recipe.sh";
50
51#[derive(Debug, Clone)]
53pub struct ImageBuild {
54 pub name: String,
56 pub recipe: String,
57 pub base: String,
58 pub description: Option<String>,
59 pub timeout: Duration,
60 pub force: bool,
62 pub built_by: String,
63}
64
65impl ImageBuild {
66 pub fn default_recipe(built_by: &str) -> ImageBuild {
68 ImageBuild {
69 name: DEFAULT_NAME.into(),
70 recipe: DEFAULT_RECIPE.into(),
71 base: DEFAULT_BASE.into(),
72 description: None,
73 timeout: DEFAULT_TIMEOUT,
74 force: false,
75 built_by: built_by.into(),
76 }
77 }
78
79 pub fn recipe_sha256(&self) -> String {
80 recipe_sha256(&self.recipe)
81 }
82
83 pub fn description(&self) -> String {
86 match &self.description {
87 Some(d) if !d.trim().is_empty() => d.trim().to_string(),
88 _ if self.recipe == DEFAULT_RECIPE => format!(
89 "isb workspace: Ubuntu 24.04, dev (uid 1000), mise (node, bun, uv), Claude Code, omp, herdr (built {})",
90 today()
91 ),
92 _ => format!(
93 "workspace image from a recipe on {} (built {})",
94 self.base,
95 today()
96 ),
97 }
98 }
99}
100
101pub fn recipe_sha256(recipe: &str) -> String {
102 let d = crate::registry::oci::digest_of(recipe.as_bytes());
103 d.trim_start_matches("sha256:").to_string()
104}
105
106fn today() -> String {
107 let t = crate::stack::now_secs() as i64;
108 crate::cron::rfc3339(t)[..10].to_string()
109}
110
111pub fn check_name(name: &str) -> Result<()> {
114 let ok = !name.is_empty()
115 && name.len() <= 63
116 && name
117 .bytes()
118 .next()
119 .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
120 && name
121 .bytes()
122 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b));
123 if ok {
124 Ok(())
125 } else {
126 Err(Error::invalid(format!(
127 "image name {name:?}: lower-case letters, digits, '.', '_' and '-', starting with a letter or digit, at most 63"
128 )))
129 }
130}
131
132pub fn check(b: &ImageBuild) -> Result<()> {
134 check_name(&b.name)?;
135 if b.recipe.trim().is_empty() {
136 return Err(Error::invalid("the recipe is empty"));
137 }
138 if b.recipe.len() > MAX_RECIPE {
139 return Err(Error::invalid(format!(
140 "the recipe is {} bytes; at most {MAX_RECIPE}",
141 b.recipe.len()
142 )));
143 }
144 if b.base.trim().is_empty() {
145 return Err(Error::invalid("base cannot be empty"));
146 }
147 crate::plan::ImageSource::parse(&b.base)?;
148 if b.timeout.is_zero() || b.timeout > MAX_TIMEOUT {
149 return Err(Error::invalid(format!(
150 "timeout: more than 0 and at most {} minutes",
151 MAX_TIMEOUT.as_secs() / 60
152 )));
153 }
154 Ok(())
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Existing {
160 pub fingerprint: String,
161 pub ours: bool,
163 pub recipe_sha256: Option<String>,
164 pub base: Option<String>,
165 pub aliases: Vec<String>,
167}
168
169impl Existing {
170 pub fn from_image(v: &Value) -> Existing {
171 let p = &v["properties"];
172 let prop = |k: &str| p[k].as_str().map(str::to_string);
173 Existing {
174 fingerprint: v["fingerprint"].as_str().unwrap_or_default().to_string(),
175 ours: p[PROP_MARK].as_str() == Some("1"),
176 recipe_sha256: prop(PROP_RECIPE),
177 base: prop(PROP_BASE),
178 aliases: v["aliases"]
179 .as_array()
180 .into_iter()
181 .flatten()
182 .filter_map(|a| a["name"].as_str().map(str::to_string))
183 .collect(),
184 }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum Plan {
191 Create,
193 UpToDate { fingerprint: String },
195 Replace { old: String },
198}
199
200pub fn plan(existing: Option<&Existing>, b: &ImageBuild) -> Result<Plan> {
202 let Some(e) = existing else {
203 return Ok(Plan::Create);
204 };
205 if !e.ours {
206 return Err(Error::AlreadyExists(format!(
207 "image {} exists and was not built by isb from a recipe; pick another name (isb replaces only the images it built)",
208 b.name
209 )));
210 }
211 let same = e.recipe_sha256.as_deref() == Some(b.recipe_sha256().as_str())
212 && e.base.as_deref() == Some(b.base.as_str());
213 if same && !b.force {
214 return Ok(Plan::UpToDate {
215 fingerprint: e.fingerprint.clone(),
216 });
217 }
218 Ok(Plan::Replace {
219 old: e.fingerprint.clone(),
220 })
221}
222
223pub fn removable(name: &str, e: &Existing) -> Result<()> {
225 if e.ours {
226 Ok(())
227 } else {
228 Err(Error::Forbidden(format!(
229 "image {name} was not built by isb from a recipe; isb removes only the images it built (use incus for others)"
230 )))
231 }
232}
233
234pub fn recipe_argv(recipe: &str) -> Vec<&'static str> {
237 if recipe.starts_with("#!") {
238 vec![RECIPE_PATH]
239 } else {
240 vec!["/bin/sh", RECIPE_PATH]
241 }
242}
243
244pub fn recipe_env() -> [(&'static str, &'static str); 4] {
246 [
247 (
248 "PATH",
249 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
250 ),
251 ("DEBIAN_FRONTEND", "noninteractive"),
252 ("HOME", "/root"),
253 ("LANG", "C.UTF-8"),
254 ]
255}
256
257pub fn properties(b: &ImageBuild) -> Value {
259 json!({
260 "description": b.description(),
261 PROP_MARK: "1",
262 PROP_RECIPE: b.recipe_sha256(),
263 PROP_BASE: b.base,
264 PROP_BUILT_BY: b.built_by,
265 PROP_BUILT_AT: crate::stack::now_secs().to_string(),
266 })
267}
268
269fn container_name(name: &str) -> String {
271 let stem: String = name
272 .chars()
273 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
274 .take(40)
275 .collect();
276 format!(
277 "wsimg-{}-{}{}",
278 stem.trim_end_matches('-'),
279 crate::stack::new_id(),
280 crate::stack::new_id()
281 )
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
286pub struct Built {
287 pub name: String,
288 pub fingerprint: String,
289 pub size: Option<u64>,
291 pub up_to_date: bool,
293 pub replaced: Option<String>,
295 pub seconds: u64,
296}
297
298static BUILDING: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
300
301struct Building(String);
302
303impl Drop for Building {
304 fn drop(&mut self) {
305 BUILDING
306 .lock()
307 .unwrap_or_else(|e| e.into_inner())
308 .remove(&self.0);
309 }
310}
311
312fn host(base: &Client) -> Client {
313 base.clone().project("default")
314}
315
316pub fn existing(base: &Client, name: &str) -> Result<Option<Existing>> {
318 let h = host(base);
319 let Some(a) = h.get_opt(&format!("/1.0/images/aliases/{}", encode_segment(name)))? else {
320 return Ok(None);
321 };
322 let fp = a["target"].as_str().unwrap_or_default();
323 let img = h.get(&format!("/1.0/images/{}", encode_segment(fp)))?;
324 Ok(Some(Existing::from_image(&img)))
325}
326
327pub fn list(base: &Client) -> Result<Vec<Value>> {
330 let all = host(base).get("/1.0/images?recursion=1")?;
331 let mut out: Vec<(u64, Value)> = all
332 .as_array()
333 .into_iter()
334 .flatten()
335 .filter(|i| i["properties"][PROP_MARK].as_str() == Some("1"))
336 .map(|i| {
337 let p = &i["properties"];
338 let at: u64 = p[PROP_BUILT_AT]
339 .as_str()
340 .and_then(|s| s.parse().ok())
341 .unwrap_or(0);
342 let aliases: Vec<&str> = i["aliases"]
343 .as_array()
344 .into_iter()
345 .flatten()
346 .filter_map(|a| a["name"].as_str())
347 .collect();
348 let v = json!({
349 "name": aliases.first(),
350 "aliases": aliases,
351 "fingerprint": i["fingerprint"],
352 "size": i["size"],
353 "description": p["description"],
354 "base": p[PROP_BASE],
355 "recipe_sha256": p[PROP_RECIPE],
356 "default_recipe": p[PROP_RECIPE].as_str() == Some(recipe_sha256(DEFAULT_RECIPE).as_str()),
357 "built_by": p[PROP_BUILT_BY],
358 "built_at": at,
359 });
360 (at, v)
361 })
362 .collect();
363 out.sort_by_key(|a| std::cmp::Reverse(a.0));
364 Ok(out.into_iter().map(|(_, v)| v).collect())
365}
366
367pub fn remove(base: &Client, name: &str) -> Result<Existing> {
370 check_name(name)?;
371 let e = existing(base, name)?.ok_or_else(|| Error::NotFound(format!("image {name}")))?;
372 removable(name, &e)?;
373 let h = host(base);
374 let t = h.timeouts.other;
375 h.mutate(
376 "DELETE",
377 &format!("/1.0/images/aliases/{}", encode_segment(name)),
378 None,
379 &format!("remove alias {name}"),
380 t,
381 )?;
382 if e.aliases.iter().all(|a| a == name) {
383 h.mutate(
384 "DELETE",
385 &format!("/1.0/images/{}", encode_segment(&e.fingerprint)),
386 None,
387 &format!("delete image {name}"),
388 t,
389 )?;
390 }
391 Ok(e)
392}
393
394fn system_project(base: &Client) -> Result<Client> {
397 let h = host(base);
398 let p = crate::registry::PROJECT;
399 if h.get_opt(&format!("/1.0/projects/{p}"))?.is_none() {
400 h.mutate(
401 "POST",
402 "/1.0/projects",
403 Some(&json!({
404 "name": p,
405 "description": "isb system services (not an org)",
406 "config": {
407 "features.images": "false",
408 "features.profiles": "true",
409 "features.storage.volumes": "true",
410 "features.networks": "false",
411 },
412 })),
413 &format!("create project {p}"),
414 h.timeouts.other,
415 )?;
416 }
417 Ok(base.clone().project(p))
418}
419
420pub fn build(base: &Client, b: &ImageBuild, log: &mut dyn FnMut(&str)) -> Result<Built> {
423 check(b)?;
424 let started = Instant::now();
425 let deadline = started + b.timeout;
426 if !BUILDING
427 .lock()
428 .unwrap_or_else(|e| e.into_inner())
429 .insert(b.name.clone())
430 {
431 return Err(Error::invalid(format!(
432 "image {} is being built already",
433 b.name
434 )));
435 }
436 let _busy = Building(b.name.clone());
437 let plan = plan(existing(base, &b.name)?.as_ref(), b)?;
438 if let Plan::UpToDate { fingerprint } = plan {
439 log(&format!(
440 "image {} is up to date (same recipe and base); force rebuilds it",
441 b.name
442 ));
443 return Ok(Built {
444 name: b.name.clone(),
445 fingerprint,
446 size: None,
447 up_to_date: true,
448 replaced: None,
449 seconds: 0,
450 });
451 }
452 let s = system_project(base)?;
453 let fingerprint = run_recipe(base, &s, b, deadline, log)?;
454 let replaced = publish_alias(base, b, &fingerprint, &plan, log)?;
455 let size = host(base)
456 .get(&format!("/1.0/images/{}", encode_segment(&fingerprint)))
457 .ok()
458 .and_then(|i| i["size"].as_u64());
459 let seconds = started.elapsed().as_secs();
460 log(&format!(
461 "published {} ({}) in {seconds}s",
462 b.name,
463 &fingerprint[..fingerprint.len().min(12)]
464 ));
465 Ok(Built {
466 name: b.name.clone(),
467 fingerprint,
468 size,
469 up_to_date: false,
470 replaced,
471 seconds,
472 })
473}
474
475fn run_recipe(
479 base: &Client,
480 s: &Client,
481 b: &ImageBuild,
482 deadline: Instant,
483 log: &mut dyn FnMut(&str),
484) -> Result<String> {
485 let h = host(base);
486 let name = container_name(&b.name);
487 let _guard = Remove {
488 client: s.clone(),
489 name: name.clone(),
490 };
491 let pool = crate::sandbox::host_facts(&h)?.pick_pool(None)?;
492 let net = uplink_network(&h)?;
493 let src = crate::plan::ImageSource::parse(&b.base)?;
494 log(&format!(
495 "launching {name} from {} in project {} (network {net})",
496 b.base,
497 crate::registry::PROJECT
498 ));
499 s.mutate(
500 "POST",
501 "/1.0/instances",
502 Some(&json!({
503 "name": name,
504 "type": "container",
505 "source": src.to_api(None),
506 "config": {"limits.cpu": "4", "limits.memory": "4GiB", "user.isb.workspace-image-build": b.name},
507 "devices": {
508 "root": {"type": "disk", "path": "/", "pool": pool},
509 "eth0": {"type": "nic", "name": "eth0", "network": net},
510 },
511 "profiles": ["default"],
512 })),
513 &format!("create {name}"),
514 remaining(deadline, "creating the build container")?.min(Duration::from_secs(1200)),
515 )?;
516 let state = format!("/1.0/instances/{}/state", encode_segment(&name));
517 s.mutate(
518 "PUT",
519 &state,
520 Some(&json!({"action": "start", "timeout": 60})),
521 &format!("start {name}"),
522 Duration::from_secs(300),
523 )?;
524 let sb = Sandbox::get(s, &name)?;
525 ready::exec(&sb, deadline)?;
526 ready::network(s, &name, &net, deadline, log)?;
527 s.push_file(&name, RECIPE_PATH, b.recipe.as_bytes(), 0, 0, 0o700)?;
528 log(&format!(
529 "running the recipe ({} bytes, sha256 {})",
530 b.recipe.len(),
531 &b.recipe_sha256()[..12]
532 ));
533 let mut opts = ExecOptions::default().timeout(remaining(deadline, "running the recipe")?);
534 for (k, v) in recipe_env() {
535 opts = opts.env(k, v);
536 }
537 let code = stream_lines(&sb, &recipe_argv(&b.recipe), opts, log)?;
538 if code != 0 {
539 return Err(Error::invalid(format!(
540 "the recipe failed (exit {code}); nothing was published"
541 )));
542 }
543 let _ = sb.exec_with(
544 ["rm", "-f", RECIPE_PATH],
545 ExecOptions::default().timeout(Duration::from_secs(30)),
546 );
547 let stop = |force: bool| {
548 s.mutate(
549 "PUT",
550 &state,
551 Some(&json!({"action": "stop", "timeout": 120, "force": force})),
552 &format!("stop {name}"),
553 Duration::from_secs(180),
554 )
555 };
556 if let Err(e) = stop(false) {
557 log(&format!("a clean stop failed ({e}); forcing it"));
558 stop(true)?;
559 }
560 log(&format!("publishing {}", b.name));
561 let op = s.mutate(
562 "POST",
563 "/1.0/images",
564 Some(&json!({
565 "source": {"type": "instance", "name": name},
566 "properties": properties(b),
567 })),
568 &format!("publish {}", b.name),
569 remaining(deadline, "publishing the image")?.min(Duration::from_secs(1800)),
570 )?;
571 op["fingerprint"]
572 .as_str()
573 .map(str::to_string)
574 .ok_or_else(|| Error::invalid("incus published the image without saying its fingerprint"))
575}
576
577fn publish_alias(
580 base: &Client,
581 b: &ImageBuild,
582 fingerprint: &str,
583 plan: &Plan,
584 log: &mut dyn FnMut(&str),
585) -> Result<Option<String>> {
586 let h = host(base);
587 let t = h.timeouts.other;
588 let desc = b.description();
589 let r = match plan {
590 Plan::Replace { .. } => h.mutate(
591 "PUT",
592 &format!("/1.0/images/aliases/{}", encode_segment(&b.name)),
593 Some(&json!({"target": fingerprint, "description": desc})),
594 &format!("move alias {}", b.name),
595 t,
596 ),
597 _ => h.mutate(
598 "POST",
599 "/1.0/images/aliases",
600 Some(&json!({"name": b.name, "target": fingerprint, "description": desc})),
601 &format!("add alias {}", b.name),
602 t,
603 ),
604 };
605 if let Err(e) = r {
606 let _ = h.mutate(
607 "DELETE",
608 &format!("/1.0/images/{}", encode_segment(fingerprint)),
609 None,
610 "delete the unaliased image",
611 t,
612 );
613 return Err(e);
614 }
615 let Plan::Replace { old } = plan else {
616 return Ok(None);
617 };
618 if old == fingerprint {
619 return Ok(None);
620 }
621 let still_named = h
622 .get_opt(&format!("/1.0/images/{}", encode_segment(old)))?
623 .is_some_and(|i| !Existing::from_image(&i).aliases.is_empty());
624 if !still_named {
625 match h.mutate(
626 "DELETE",
627 &format!("/1.0/images/{}", encode_segment(old)),
628 None,
629 "delete the replaced image",
630 t,
631 ) {
632 Ok(_) => log(&format!(
633 "deleted the image it replaced ({})",
634 &old[..old.len().min(12)]
635 )),
636 Err(e) if e.is_not_found() => {}
637 Err(e) => log(&format!("the image it replaced was not deleted: {e}")),
638 }
639 }
640 Ok(Some(old.clone()))
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 fn req(recipe: &str) -> ImageBuild {
648 ImageBuild {
649 name: "team-box".into(),
650 recipe: recipe.into(),
651 base: DEFAULT_BASE.into(),
652 description: None,
653 timeout: DEFAULT_TIMEOUT,
654 force: false,
655 built_by: "a@x.io".into(),
656 }
657 }
658
659 fn image(ours: bool, recipe: &str, aliases: &[&str]) -> Existing {
660 Existing {
661 fingerprint: "f00d".into(),
662 ours,
663 recipe_sha256: Some(recipe_sha256(recipe)),
664 base: Some(DEFAULT_BASE.into()),
665 aliases: aliases.iter().map(|a| a.to_string()).collect(),
666 }
667 }
668
669 #[test]
670 fn the_plan_creates_skips_replaces_and_never_takes_another_image() {
671 let b = req("apt-get install -y git\n");
672 assert_eq!(plan(None, &b).unwrap(), Plan::Create);
673 let same = image(true, &b.recipe, &["team-box"]);
675 assert_eq!(
676 plan(Some(&same), &b).unwrap(),
677 Plan::UpToDate {
678 fingerprint: "f00d".into()
679 }
680 );
681 let forced = ImageBuild {
683 force: true,
684 ..b.clone()
685 };
686 assert_eq!(
687 plan(Some(&same), &forced).unwrap(),
688 Plan::Replace { old: "f00d".into() }
689 );
690 let other = image(true, "echo old\n", &["team-box"]);
691 assert!(matches!(
692 plan(Some(&other), &b).unwrap(),
693 Plan::Replace { .. }
694 ));
695 let on_debian = ImageBuild {
696 base: "images:debian/12".into(),
697 ..b.clone()
698 };
699 assert!(matches!(
700 plan(Some(&same), &on_debian).unwrap(),
701 Plan::Replace { .. }
702 ));
703 let theirs = image(false, &b.recipe, &["team-box"]);
705 assert!(plan(Some(&theirs), &forced).is_err());
706 }
707
708 #[test]
709 fn only_isbs_images_can_be_removed() {
710 let ours = Existing::from_image(&json!({
711 "fingerprint": "abc",
712 "aliases": [{"name": "isb-workspace"}],
713 "properties": {PROP_MARK: "1", PROP_RECIPE: "x"}
714 }));
715 assert!(ours.ours);
716 assert_eq!(ours.aliases, ["isb-workspace"]);
717 assert!(removable("isb-workspace", &ours).is_ok());
718 let dev_base = Existing::from_image(&json!({
719 "fingerprint": "def",
720 "aliases": [{"name": "dev-base"}],
721 "properties": {"description": "dev-base: Ubuntu 24.04"}
722 }));
723 assert!(!dev_base.ours);
724 let e = removable("dev-base", &dev_base).unwrap_err().to_string();
725 assert!(e.contains("not built by isb"), "{e}");
726 let fake = Existing::from_image(&json!({"properties": {PROP_MARK: "true"}}));
728 assert!(!fake.ours);
729 }
730
731 #[test]
732 fn the_recipe_runs_as_root_with_a_plain_environment() {
733 assert_eq!(recipe_argv("#!/bin/bash\necho hi\n"), [RECIPE_PATH]);
734 assert_eq!(recipe_argv("echo hi\n"), ["/bin/sh", RECIPE_PATH]);
735 let env: Vec<&str> = recipe_env().iter().map(|(k, _)| *k).collect();
736 assert_eq!(env, ["PATH", "DEBIAN_FRONTEND", "HOME", "LANG"]);
737 let b = req("echo hi\n");
738 let p = properties(&b);
739 assert_eq!(p[PROP_MARK], "1");
740 assert_eq!(p[PROP_RECIPE], recipe_sha256("echo hi\n"));
741 assert_eq!(p[PROP_BASE], DEFAULT_BASE);
742 assert_eq!(p[PROP_BUILT_BY], "a@x.io");
743 assert!(p["description"].as_str().unwrap().contains(DEFAULT_BASE));
744 let n = container_name("team.box");
745 assert!(n.starts_with("wsimg-team-box-") && n.len() <= 63, "{n}");
746 }
747
748 #[test]
749 fn requests_are_checked_before_anything_is_made() {
750 assert!(check(&req("echo hi")).is_ok());
751 assert!(check(&ImageBuild::default_recipe("x")).is_ok());
752 for bad in ["", "Box", "-x", "a/b", "images:ubuntu", &"a".repeat(64)] {
753 assert!(check_name(bad).is_err(), "{bad}");
754 }
755 assert!(check(&req(" \n")).is_err());
756 assert!(check(&req(&"x".repeat(MAX_RECIPE + 1))).is_err());
757 let slow = ImageBuild {
758 timeout: MAX_TIMEOUT + Duration::from_secs(1),
759 ..req("echo")
760 };
761 assert!(check(&slow).is_err());
762 }
763
764 #[test]
765 fn the_default_recipe_installs_outside_the_home_and_bakes_in_no_credentials() {
766 let r = DEFAULT_RECIPE;
767 assert!(r.starts_with("#!/bin/sh"));
768 for tool in [
769 "useradd -m -u 1000",
770 "openssh-server",
771 "build-essential",
772 "MISE_INSTALL_PATH=/usr/local/bin/mise",
773 "mise install --system",
774 "/usr/local/bin/claude",
775 "PI_INSTALL_DIR=/usr/local/bin",
776 "HERDR_INSTALL_DIR=/usr/local/bin",
777 "rm -f /etc/ssh/ssh_host_",
778 ] {
779 assert!(r.contains(tool), "{tool}");
780 }
781 for secret in ["API_KEY", "TOKEN=", "PASSWORD", "authorized_keys"] {
782 assert!(!r.contains(secret), "{secret}");
783 }
784 }
785}