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: '{}' 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 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
184fn 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
192fn 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
200pub 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}