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