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