1use std::path::Path;
2use std::process::{Command, ExitCode, Stdio};
3
4use crate::cli::{CleanCommand, Cli};
5use crate::config::{LoadOptions, load_config};
6use crate::error::SboxError;
7
8pub fn execute(cli: &Cli, command: &CleanCommand) -> Result<ExitCode, SboxError> {
9 let scope = CleanScope::from_command(command);
10 let loaded = load_config(&LoadOptions {
11 workspace: cli.workspace.clone(),
12 config: cli.config.clone(),
13 })?;
14
15 let mut removed = Vec::new();
16
17 if scope.sessions {
18 let session_names = reusable_session_names(&loaded.config, &loaded.workspace_root);
19 if session_names.is_empty() {
20 println!("sessions: no reusable sessions configured for this workspace");
21 } else {
22 for name in session_names {
23 remove_podman_container(&name)?;
24 removed.push(format!("session:{name}"));
25 }
26 }
27 }
28
29 if scope.images {
30 if let Some(tag) = derived_image_tag(&loaded.config, &loaded.workspace_root) {
31 remove_podman_image(&tag)?;
32 removed.push(format!("image:{tag}"));
33 } else {
34 println!("images: no workspace-derived image configured");
35 }
36 }
37
38 if scope.caches {
39 let names = cache_volume_names(&loaded.config.caches, &loaded.workspace_root);
40 if names.is_empty() {
41 println!("caches: no workspace caches configured");
42 } else {
43 for name in names {
44 remove_podman_volume(&name)?;
45 removed.push(format!("cache:{name}"));
46 }
47 }
48 }
49
50 if removed.is_empty() {
51 println!("clean: nothing removed");
52 } else {
53 println!("clean: removed {}", removed.join(", "));
54 }
55
56 Ok(ExitCode::SUCCESS)
57}
58
59#[derive(Debug, Clone, Copy)]
60struct CleanScope {
61 sessions: bool,
62 images: bool,
63 caches: bool,
64}
65
66impl CleanScope {
67 fn from_command(command: &CleanCommand) -> Self {
68 if command.all {
69 return Self {
70 sessions: true,
71 images: true,
72 caches: true,
73 };
74 }
75
76 if !command.sessions && !command.images && !command.caches {
77 return Self {
78 sessions: true,
79 images: false,
80 caches: false,
81 };
82 }
83
84 Self {
85 sessions: command.sessions,
86 images: command.images,
87 caches: command.caches,
88 }
89 }
90}
91
92fn derived_image_tag(
93 config: &crate::config::model::Config,
94 workspace_root: &Path,
95) -> Option<String> {
96 let image = config.image.as_ref()?;
97 let build = image.build.as_ref()?;
98 if let Some(tag) = &image.tag {
99 return Some(tag.clone());
100 }
101
102 let recipe_path = if build.is_absolute() {
103 build.clone()
104 } else {
105 workspace_root.join(build)
106 };
107
108 Some(format!(
109 "sbox-build-{}",
110 stable_hash(&recipe_path.display().to_string())
111 ))
112}
113
114fn cache_volume_names(
115 caches: &[crate::config::model::CacheConfig],
116 workspace_root: &Path,
117) -> Vec<String> {
118 caches
119 .iter()
120 .filter(|cache| cache.source.is_none())
121 .map(|cache| {
122 format!(
123 "sbox-cache-{}-{}",
124 stable_hash(&workspace_root.display().to_string()),
125 sanitize_volume_name(&cache.name)
126 )
127 })
128 .collect()
129}
130
131fn reusable_session_names(
132 config: &crate::config::model::Config,
133 workspace_root: &Path,
134) -> Vec<String> {
135 let runtime_reuse = config
136 .runtime
137 .as_ref()
138 .and_then(|runtime| runtime.reuse_container)
139 .unwrap_or(false);
140 let template = config
141 .runtime
142 .as_ref()
143 .and_then(|runtime| runtime.container_name.as_ref());
144
145 config
146 .profiles
147 .iter()
148 .filter(|(_, profile)| profile.reuse_container.unwrap_or(runtime_reuse))
149 .map(|(profile_name, _)| reusable_session_name(template, workspace_root, profile_name))
150 .collect()
151}
152
153fn remove_podman_image(tag: &str) -> Result<(), SboxError> {
154 let status = Command::new("podman")
155 .args(["image", "rm", "-f", tag])
156 .stdin(Stdio::null())
157 .stdout(Stdio::null())
158 .stderr(Stdio::null())
159 .status()
160 .map_err(|source| SboxError::BackendUnavailable {
161 backend: "podman".to_string(),
162 source,
163 })?;
164
165 if status.success() || status.code() == Some(1) {
166 Ok(())
167 } else {
168 Err(SboxError::BackendCommandFailed {
169 backend: "podman".to_string(),
170 command: format!("podman image rm -f {tag}"),
171 status: status.code().unwrap_or(1),
172 })
173 }
174}
175
176fn remove_podman_volume(name: &str) -> Result<(), SboxError> {
177 let status = Command::new("podman")
178 .args(["volume", "rm", "-f", name])
179 .stdin(Stdio::null())
180 .stdout(Stdio::null())
181 .stderr(Stdio::null())
182 .status()
183 .map_err(|source| SboxError::BackendUnavailable {
184 backend: "podman".to_string(),
185 source,
186 })?;
187
188 if status.success() || status.code() == Some(1) {
189 Ok(())
190 } else {
191 Err(SboxError::BackendCommandFailed {
192 backend: "podman".to_string(),
193 command: format!("podman volume rm -f {name}"),
194 status: status.code().unwrap_or(1),
195 })
196 }
197}
198
199fn remove_podman_container(name: &str) -> Result<(), SboxError> {
200 let status = Command::new("podman")
201 .args(["rm", "-f", name])
202 .stdin(Stdio::null())
203 .stdout(Stdio::null())
204 .stderr(Stdio::null())
205 .status()
206 .map_err(|source| SboxError::BackendUnavailable {
207 backend: "podman".to_string(),
208 source,
209 })?;
210
211 if status.success() || status.code() == Some(1) {
212 Ok(())
213 } else {
214 Err(SboxError::BackendCommandFailed {
215 backend: "podman".to_string(),
216 command: format!("podman rm -f {name}"),
217 status: status.code().unwrap_or(1),
218 })
219 }
220}
221
222fn sanitize_volume_name(name: &str) -> String {
223 name.chars()
224 .map(|ch| {
225 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-' {
226 ch
227 } else {
228 '-'
229 }
230 })
231 .collect()
232}
233
234fn stable_hash(input: &str) -> String {
235 let mut hash = 0xcbf29ce484222325u64;
236 for byte in input.as_bytes() {
237 hash ^= u64::from(*byte);
238 hash = hash.wrapping_mul(0x100000001b3);
239 }
240 format!("{hash:016x}")
241}
242
243fn reusable_session_name(
244 template: Option<&String>,
245 workspace_root: &Path,
246 profile_name: &str,
247) -> String {
248 let workspace_hash = stable_hash(&workspace_root.display().to_string());
249 let base = template
250 .map(|template| {
251 template
252 .replace("{profile}", profile_name)
253 .replace("{workspace_hash}", &workspace_hash)
254 })
255 .unwrap_or_else(|| format!("sbox-{workspace_hash}-{profile_name}"));
256
257 sanitize_volume_name(&base)
258}
259
260#[cfg(test)]
261mod tests {
262 use super::{CleanScope, cache_volume_names, reusable_session_names};
263 use crate::cli::CleanCommand;
264 use crate::config::model::{
265 BackendKind, CacheConfig, Config, ExecutionMode, ProfileConfig, RuntimeConfig,
266 };
267 use indexmap::IndexMap;
268 use std::path::Path;
269
270 #[test]
271 fn defaults_to_sessions_only() {
272 let scope = CleanScope::from_command(&CleanCommand::default());
273 assert!(scope.sessions);
274 assert!(!scope.images);
275 assert!(!scope.caches);
276 }
277
278 #[test]
279 fn all_flag_enables_every_cleanup_target() {
280 let scope = CleanScope::from_command(&CleanCommand {
281 all: true,
282 ..CleanCommand::default()
283 });
284 assert!(scope.sessions);
285 assert!(scope.images);
286 assert!(scope.caches);
287 }
288
289 #[test]
290 fn cache_volume_names_only_include_implicit_volumes() {
291 let caches = vec![
292 CacheConfig {
293 name: "first".into(),
294 target: "/cache".into(),
295 source: None,
296 read_only: None,
297 },
298 CacheConfig {
299 name: "host".into(),
300 target: "/host".into(),
301 source: Some("./cache".into()),
302 read_only: None,
303 },
304 ];
305
306 let names = cache_volume_names(&caches, Path::new("/tmp/workspace"));
307 assert_eq!(names.len(), 1);
308 assert!(names[0].contains("sbox-cache-"));
309 }
310
311 #[test]
312 fn reusable_session_names_follow_runtime_defaults() {
313 let mut profiles = IndexMap::new();
314 profiles.insert(
315 "default".to_string(),
316 ProfileConfig {
317 mode: ExecutionMode::Sandbox,
318 image: None,
319 network: Some("off".into()),
320 writable: Some(true),
321 require_pinned_image: None,
322 require_lockfile: None,
323 role: None,
324 lockfile_files: Vec::new(),
325 pre_run: Vec::new(),
326 network_allow: Vec::new(),
327 ports: Vec::new(),
328 capabilities: None,
329 no_new_privileges: Some(true),
330 read_only_rootfs: None,
331 reuse_container: None,
332 shell: None,
333
334 writable_paths: None,
335 },
336 );
337 let config = Config {
338 version: 1,
339 runtime: Some(RuntimeConfig {
340 backend: Some(BackendKind::Podman),
341 rootless: Some(true),
342 reuse_container: Some(true),
343 container_name: None,
344 pull_policy: None,
345 strict_security: None,
346 require_pinned_image: None,
347 }),
348 workspace: None,
349 identity: None,
350 image: None,
351 environment: None,
352 mounts: Vec::new(),
353 caches: Vec::new(),
354 secrets: Vec::new(),
355 profiles,
356 dispatch: IndexMap::new(),
357
358 package_manager: None,
359 };
360
361 let names = reusable_session_names(&config, Path::new("/tmp/workspace"));
362 assert_eq!(names.len(), 1);
363 assert!(names[0].starts_with("sbox-"));
364 }
365}