1use std::collections::BTreeSet;
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use serde_json::{Value, json};
22
23use super::{Remove, owned, ready, reap_stale, 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 reap_stale(s, false, log);
488 let _guard = Remove {
489 client: s.clone(),
490 name: name.clone(),
491 };
492 let pool = crate::sandbox::host_facts(&h)?.pick_pool(None)?;
493 let net = uplink_network(&h)?;
494 let src = crate::plan::ImageSource::parse(&b.base)?;
495 log(&format!(
496 "launching {name} from {} in project {} (network {net})",
497 b.base,
498 crate::registry::PROJECT
499 ));
500 s.mutate(
501 "POST",
502 "/1.0/instances",
503 Some(&json!({
504 "name": name,
505 "type": "container",
506 "source": src.to_api(None),
507 "config": owned(json!({"limits.cpu": "4", "limits.memory": "4GiB", "user.isb.workspace-image-build": b.name})),
508 "devices": {
509 "root": {"type": "disk", "path": "/", "pool": pool},
510 "eth0": {"type": "nic", "name": "eth0", "network": net},
511 },
512 "profiles": ["default"],
513 })),
514 &format!("create {name}"),
515 remaining(deadline, "creating the build container")?.min(Duration::from_secs(1200)),
516 )?;
517 let state = format!("/1.0/instances/{}/state", encode_segment(&name));
518 s.mutate(
519 "PUT",
520 &state,
521 Some(&json!({"action": "start", "timeout": 60})),
522 &format!("start {name}"),
523 Duration::from_secs(300),
524 )?;
525 let sb = Sandbox::get(s, &name)?;
526 ready::exec(&sb, deadline)?;
527 ready::network(s, &name, &net, deadline, log)?;
528 ready::resolver(&sb, deadline)?;
529 s.push_file(&name, RECIPE_PATH, b.recipe.as_bytes(), 0, 0, 0o700)?;
530 log(&format!(
531 "running the recipe ({} bytes, sha256 {})",
532 b.recipe.len(),
533 &b.recipe_sha256()[..12]
534 ));
535 let mut opts = ExecOptions::default().timeout(remaining(deadline, "running the recipe")?);
536 for (k, v) in recipe_env() {
537 opts = opts.env(k, v);
538 }
539 let code = stream_lines(&sb, &recipe_argv(&b.recipe), opts, log)?;
540 if code != 0 {
541 return Err(Error::invalid(format!(
542 "the recipe failed (exit {code}); nothing was published"
543 )));
544 }
545 let _ = sb.exec_with(
546 ["rm", "-f", RECIPE_PATH],
547 ExecOptions::default().timeout(Duration::from_secs(30)),
548 );
549 let stop = |force: bool| {
550 s.mutate(
551 "PUT",
552 &state,
553 Some(&json!({"action": "stop", "timeout": 120, "force": force})),
554 &format!("stop {name}"),
555 Duration::from_secs(180),
556 )
557 };
558 if let Err(e) = stop(false) {
559 log(&format!("a clean stop failed ({e}); forcing it"));
560 stop(true)?;
561 }
562 log(&format!("publishing {}", b.name));
563 let op = s.mutate(
564 "POST",
565 "/1.0/images",
566 Some(&json!({
567 "source": {"type": "instance", "name": name},
568 "properties": properties(b),
569 })),
570 &format!("publish {}", b.name),
571 remaining(deadline, "publishing the image")?.min(Duration::from_secs(1800)),
572 )?;
573 op["fingerprint"]
574 .as_str()
575 .map(str::to_string)
576 .ok_or_else(|| Error::invalid("incus published the image without saying its fingerprint"))
577}
578
579fn publish_alias(
582 base: &Client,
583 b: &ImageBuild,
584 fingerprint: &str,
585 plan: &Plan,
586 log: &mut dyn FnMut(&str),
587) -> Result<Option<String>> {
588 let h = host(base);
589 let t = h.timeouts.other;
590 let desc = b.description();
591 let r = match plan {
592 Plan::Replace { .. } => h.mutate(
593 "PUT",
594 &format!("/1.0/images/aliases/{}", encode_segment(&b.name)),
595 Some(&json!({"target": fingerprint, "description": desc})),
596 &format!("move alias {}", b.name),
597 t,
598 ),
599 _ => h.mutate(
600 "POST",
601 "/1.0/images/aliases",
602 Some(&json!({"name": b.name, "target": fingerprint, "description": desc})),
603 &format!("add alias {}", b.name),
604 t,
605 ),
606 };
607 if let Err(e) = r {
608 let _ = h.mutate(
609 "DELETE",
610 &format!("/1.0/images/{}", encode_segment(fingerprint)),
611 None,
612 "delete the unaliased image",
613 t,
614 );
615 return Err(e);
616 }
617 let Plan::Replace { old } = plan else {
618 return Ok(None);
619 };
620 if old == fingerprint {
621 return Ok(None);
622 }
623 let still_named = h
624 .get_opt(&format!("/1.0/images/{}", encode_segment(old)))?
625 .is_some_and(|i| !Existing::from_image(&i).aliases.is_empty());
626 if !still_named {
627 match h.mutate(
628 "DELETE",
629 &format!("/1.0/images/{}", encode_segment(old)),
630 None,
631 "delete the replaced image",
632 t,
633 ) {
634 Ok(_) => log(&format!(
635 "deleted the image it replaced ({})",
636 &old[..old.len().min(12)]
637 )),
638 Err(e) if e.is_not_found() => {}
639 Err(e) => log(&format!("the image it replaced was not deleted: {e}")),
640 }
641 }
642 Ok(Some(old.clone()))
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 fn req(recipe: &str) -> ImageBuild {
650 ImageBuild {
651 name: "team-box".into(),
652 recipe: recipe.into(),
653 base: DEFAULT_BASE.into(),
654 description: None,
655 timeout: DEFAULT_TIMEOUT,
656 force: false,
657 built_by: "a@x.io".into(),
658 }
659 }
660
661 fn image(ours: bool, recipe: &str, aliases: &[&str]) -> Existing {
662 Existing {
663 fingerprint: "f00d".into(),
664 ours,
665 recipe_sha256: Some(recipe_sha256(recipe)),
666 base: Some(DEFAULT_BASE.into()),
667 aliases: aliases.iter().map(|a| a.to_string()).collect(),
668 }
669 }
670
671 #[test]
672 fn the_plan_creates_skips_replaces_and_never_takes_another_image() {
673 let b = req("apt-get install -y git\n");
674 assert_eq!(plan(None, &b).unwrap(), Plan::Create);
675 let same = image(true, &b.recipe, &["team-box"]);
677 assert_eq!(
678 plan(Some(&same), &b).unwrap(),
679 Plan::UpToDate {
680 fingerprint: "f00d".into()
681 }
682 );
683 let forced = ImageBuild {
685 force: true,
686 ..b.clone()
687 };
688 assert_eq!(
689 plan(Some(&same), &forced).unwrap(),
690 Plan::Replace { old: "f00d".into() }
691 );
692 let other = image(true, "echo old\n", &["team-box"]);
693 assert!(matches!(
694 plan(Some(&other), &b).unwrap(),
695 Plan::Replace { .. }
696 ));
697 let on_debian = ImageBuild {
698 base: "images:debian/12".into(),
699 ..b.clone()
700 };
701 assert!(matches!(
702 plan(Some(&same), &on_debian).unwrap(),
703 Plan::Replace { .. }
704 ));
705 let theirs = image(false, &b.recipe, &["team-box"]);
707 assert!(plan(Some(&theirs), &forced).is_err());
708 }
709
710 #[test]
711 fn only_isbs_images_can_be_removed() {
712 let ours = Existing::from_image(&json!({
713 "fingerprint": "abc",
714 "aliases": [{"name": "isb-workspace"}],
715 "properties": {PROP_MARK: "1", PROP_RECIPE: "x"}
716 }));
717 assert!(ours.ours);
718 assert_eq!(ours.aliases, ["isb-workspace"]);
719 assert!(removable("isb-workspace", &ours).is_ok());
720 let dev_base = Existing::from_image(&json!({
721 "fingerprint": "def",
722 "aliases": [{"name": "dev-base"}],
723 "properties": {"description": "dev-base: Ubuntu 24.04"}
724 }));
725 assert!(!dev_base.ours);
726 let e = removable("dev-base", &dev_base).unwrap_err().to_string();
727 assert!(e.contains("not built by isb"), "{e}");
728 let fake = Existing::from_image(&json!({"properties": {PROP_MARK: "true"}}));
730 assert!(!fake.ours);
731 }
732
733 #[test]
734 fn the_recipe_runs_as_root_with_a_plain_environment() {
735 assert_eq!(recipe_argv("#!/bin/bash\necho hi\n"), [RECIPE_PATH]);
736 assert_eq!(recipe_argv("echo hi\n"), ["/bin/sh", RECIPE_PATH]);
737 let env: Vec<&str> = recipe_env().iter().map(|(k, _)| *k).collect();
738 assert_eq!(env, ["PATH", "DEBIAN_FRONTEND", "HOME", "LANG"]);
739 let b = req("echo hi\n");
740 let p = properties(&b);
741 assert_eq!(p[PROP_MARK], "1");
742 assert_eq!(p[PROP_RECIPE], recipe_sha256("echo hi\n"));
743 assert_eq!(p[PROP_BASE], DEFAULT_BASE);
744 assert_eq!(p[PROP_BUILT_BY], "a@x.io");
745 assert!(p["description"].as_str().unwrap().contains(DEFAULT_BASE));
746 let n = container_name("team.box");
747 assert!(n.starts_with("wsimg-team-box-") && n.len() <= 63, "{n}");
748 }
749
750 #[test]
751 fn requests_are_checked_before_anything_is_made() {
752 assert!(check(&req("echo hi")).is_ok());
753 assert!(check(&ImageBuild::default_recipe("x")).is_ok());
754 for bad in ["", "Box", "-x", "a/b", "images:ubuntu", &"a".repeat(64)] {
755 assert!(check_name(bad).is_err(), "{bad}");
756 }
757 assert!(check(&req(" \n")).is_err());
758 assert!(check(&req(&"x".repeat(MAX_RECIPE + 1))).is_err());
759 let slow = ImageBuild {
760 timeout: MAX_TIMEOUT + Duration::from_secs(1),
761 ..req("echo")
762 };
763 assert!(check(&slow).is_err());
764 }
765
766 #[test]
767 fn the_default_recipe_installs_outside_the_home_and_bakes_in_no_credentials() {
768 let r = DEFAULT_RECIPE;
769 assert!(r.starts_with("#!/bin/sh"));
770 for tool in [
771 "useradd -m -u 1000",
772 "openssh-server",
773 "build-essential",
774 "MISE_INSTALL_PATH=/usr/local/bin/mise",
775 "mise install --system",
776 "/usr/local/bin/claude",
777 "PI_INSTALL_DIR=/usr/local/bin",
778 "HERDR_INSTALL_DIR=/usr/local/bin",
779 "rm -f /etc/ssh/ssh_host_",
780 ] {
781 assert!(r.contains(tool), "{tool}");
782 }
783 for secret in ["API_KEY", "TOKEN=", "PASSWORD", "authorized_keys"] {
784 assert!(!r.contains(secret), "{secret}");
785 }
786 }
787}