podbox/config/
validation.rs1use 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 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
197fn 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
205fn 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
213pub 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}