Skip to main content

podbox/config/
validation.rs

1use anyhow::Result;
2
3use crate::config::Config;
4use crate::error::PodboxError;
5
6impl Config {
7    pub fn validate(&self) -> Result<()> {
8        let mut errors: Vec<String> = Vec::new();
9
10        if self.image.base.trim().is_empty() {
11            errors.push("image.base: must not be empty".into());
12        }
13        if self.image.name.trim().is_empty() {
14            errors.push("image.name: must not be empty".into());
15        } else if !is_valid_name(&self.image.name) {
16            errors.push(format!(
17                "image.name: '{}' contains invalid characters (use letters, digits, hyphens, underscores, dots)",
18                self.image.name
19            ));
20        }
21        if let Some(ref r) = self.image.image_ref {
22            if r.trim().is_empty() {
23                errors.push("image.image: must not be empty when set".into());
24            } else if !r.contains(':') && !r.contains('/') {
25                errors.push(format!(
26                    "image.image: '{r}' does not look like a valid image reference (missing ':' or '/')"
27                ));
28            }
29        }
30
31        if self.container.name.trim().is_empty() {
32            errors.push("container.name: must not be empty".into());
33        } else if !is_valid_name(&self.container.name) {
34            errors.push(format!(
35                "container.name: '{}' contains invalid characters (use letters, digits, hyphens, underscores, dots)",
36                self.container.name
37            ));
38        }
39        if self.container.home.as_os_str().is_empty() {
40            errors.push("container.home: must not be empty".into());
41        }
42        if self.container.shell.trim().is_empty() {
43            errors.push("container.shell: must not be empty".into());
44        }
45        if let Some(ref mem) = self.container.memory {
46            if !is_valid_memory(mem) {
47                errors.push(format!(
48                    "container.memory: '{mem}' is not a valid memory limit (e.g. '2g', '512m')"
49                ));
50            }
51        }
52        if let Some(ref cpus) = self.container.cpus {
53            if cpus.parse::<f64>().is_err() || cpus.parse::<f64>().unwrap_or(0.0) <= 0.0 {
54                errors.push(format!(
55                    "container.cpus: '{cpus}' is not a valid CPU count (e.g. '2.0', '0.5')"
56                ));
57            }
58        }
59        for (i, mount) in self.container.mounts.extra.iter().enumerate() {
60            if !mount.contains(':') {
61                errors.push(format!(
62                    "container.mounts.extra[{i}]: '{mount}' missing ':' separator (expected host:container[:options])"
63                ));
64            }
65        }
66        for (key, val) in &self.container.env {
67            if key.contains('\n') {
68                errors.push(format!("container.env: key {key:?} contains newline"));
69            }
70            if val.contains('\n') {
71                errors.push(format!("container.env: value for {key:?} contains newline"));
72            }
73        }
74
75        if let Some(ref userns) = self.security.userns {
76            let valid_userns = ["keep-id", "nomap", "private"];
77            if !valid_userns.contains(&userns.as_str()) {
78                errors.push(format!(
79                    "security.userns: '{}' is invalid (expected one of: {})",
80                    userns,
81                    valid_userns.join(", ")
82                ));
83            }
84        }
85
86        // Network validation
87        let valid_modes = ["host", "bridge", "none", "pasta", "slirp4netns", "private"];
88        if !valid_modes.contains(&self.network.mode.as_str()) {
89            errors.push(format!(
90                "network.mode: '{}' is invalid (expected one of: {})",
91                self.network.mode,
92                valid_modes.join(", ")
93            ));
94        }
95
96        for (i, port) in self.network.ports.iter().enumerate() {
97            if !port.contains(':') {
98                errors.push(format!(
99                    "network.ports[{i}]: '{port}' is invalid (expected 'hostPort:containerPort' or 'ip:hostPort:containerPort')"
100                ));
101            }
102        }
103
104        if let Some(ref map) = self.integration.host_exec.allowlist {
105            for (alias, entry) in map {
106                let path = entry.path();
107                if !is_absolute_path(path) {
108                    errors.push(format!(
109                        "integration.host_exec.allowlist.{alias}: path '{path}' is not absolute (must start with '/')"
110                    ));
111                }
112                if alias.is_empty()
113                    || alias.contains('/')
114                    || alias.contains("..")
115                    || alias.contains('\0')
116                {
117                    errors.push(format!(
118                        "integration.host_exec.allowlist: alias '{alias}' is invalid (must not contain '/' or '..')"
119                    ));
120                }
121                const RESERVED: &[&str] = &[
122                    "podbox-guest",
123                    "podmgr-guest",
124                    "host-exec",
125                    "notify-send",
126                    "xdg-open",
127                    "podbox-clipboard",
128                    "podmgr-clipboard",
129                ];
130                if RESERVED.contains(&alias.as_str()) {
131                    errors.push(format!(
132                        "integration.host_exec.allowlist: alias '{alias}' is reserved"
133                    ));
134                }
135            }
136        }
137
138        if self.integration.host_exec.enabled {
139            let has_allowlist = self
140                .integration
141                .host_exec
142                .allowlist
143                .as_ref()
144                .is_some_and(|m| !m.is_empty());
145            if !has_allowlist {
146                errors.push(
147                    "integration.host_exec: 'enabled' is true, but 'allowlist' is missing or empty. \
148                     For security, legacy open execution is blocked; you must explicitly define \
149                     allowed host commands."
150                        .into(),
151                );
152            }
153        }
154
155        for svc in &self.dbus.talk {
156            if is_portal_family(svc) {
157                eprintln!(
158                    "warning: dbus.talk entry '{svc}' grants the container access to the full \
159                     xdg-desktop-portal bus surface (DynamicLauncher, Screenshot, ScreenCast, \
160                     Settings, ...). Prefer relying on the built-in interface-scoped portal rules \
161                     from integration.notify / integration.xdg_open instead."
162                );
163            }
164        }
165
166        let t = &self.lifecycle.idle_timeout;
167        if t != "off" {
168            let (digits, suffix) = parse_duration_suffix(t);
169            if digits.is_empty() || !matches!(suffix, Some('s' | 'm' | 'h')) {
170                errors.push(format!(
171                    "lifecycle.idle_timeout: '{t}' is invalid (expected 'off', '30s', '5m', '1h')"
172                ));
173            }
174        }
175
176        if errors.is_empty() {
177            Ok(())
178        } else {
179            Err(PodboxError::ConfigValidationFailed {
180                details: errors.join("\n  - "),
181            }
182            .into())
183        }
184    }
185}
186
187fn is_valid_name(s: &str) -> bool {
188    !s.is_empty()
189        && s.chars()
190            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
191}
192
193fn is_absolute_path(s: &str) -> bool {
194    s.starts_with('/')
195}
196
197/// True when `svc` names the xdg-desktop-portal bus surface (the Desktop
198/// service itself or anything under its name prefix).
199fn is_portal_family(svc: &str) -> bool {
200    svc == "org.freedesktop.portal.Desktop"
201        || svc.starts_with("org.freedesktop.portal")
202        || svc.starts_with("org.freedesktop.impl.portal")
203}
204
205/// Parse a duration string into (digit_part, suffix_char).
206fn parse_duration_suffix(s: &str) -> (String, Option<char>) {
207    let trimmed = s.trim();
208    let digits: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect();
209    let suffix = trimmed.chars().nth(digits.len());
210    (digits, suffix)
211}
212
213/// Convert an idle_timeout config string to seconds.
214/// Returns 0 for "off".
215pub fn parse_idle_timeout_secs(s: &str) -> u64 {
216    if s == "off" {
217        return 0;
218    }
219    let (digits, suffix) = parse_duration_suffix(s);
220    let value: u64 = digits.parse().unwrap_or(0);
221    match suffix {
222        Some('s') => value,
223        Some('m') => value.saturating_mul(60),
224        Some('h') => value.saturating_mul(3600),
225        _ => 0,
226    }
227}
228
229pub fn is_valid_memory(s: &str) -> bool {
230    let s = s.trim();
231    if s.is_empty() {
232        return false;
233    }
234    let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
235    let suffix: String = s.chars().skip(digits.len()).collect();
236    if digits.is_empty() {
237        return false;
238    }
239    matches!(
240        suffix.as_str(),
241        "k" | "K" | "m" | "M" | "g" | "G" | "t" | "T"
242    )
243}
244
245pub fn is_bare_memory_digits(s: &str) -> bool {
246    let t = s.trim();
247    !t.is_empty() && t.chars().all(|c| c.is_ascii_digit())
248}