Skip to main content

sbox/
clean.rs

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}