1use crate::error::Error;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Preset {
13 Minimal,
14 Web,
15}
16
17impl std::str::FromStr for Preset {
18 type Err = String;
19
20 fn from_str(s: &str) -> Result<Preset, String> {
21 match s {
22 "minimal" => Ok(Preset::Minimal),
23 "web" => Ok(Preset::Web),
24 other => Err(format!(
25 "unknown preset `{other}` (expected minimal or web)"
26 )),
27 }
28 }
29}
30
31impl std::fmt::Display for Preset {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 f.write_str(match self {
34 Preset::Minimal => "minimal",
35 Preset::Web => "web",
36 })
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum RuntimeFeature {
42 CaCertificates,
43 Tmp,
44 PasswdGroup,
45 Nsswitch,
46 Tzdata,
47 LdSoCache,
48}
49
50impl RuntimeFeature {
51 pub fn as_str(&self) -> &'static str {
52 match self {
53 RuntimeFeature::CaCertificates => "ca-certificates",
54 RuntimeFeature::Tmp => "tmp",
55 RuntimeFeature::PasswdGroup => "passwd-group",
56 RuntimeFeature::Nsswitch => "nsswitch",
57 RuntimeFeature::Tzdata => "tzdata",
58 RuntimeFeature::LdSoCache => "ld-so-cache",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub enum CachePolicy {
72 #[default]
73 Auto,
74 Always,
75 Never,
76}
77
78impl CachePolicy {
79 pub fn as_str(&self) -> &'static str {
80 match self {
81 CachePolicy::Auto => "auto",
82 CachePolicy::Always => "always",
83 CachePolicy::Never => "never",
84 }
85 }
86
87 pub fn from_flag(value: Option<bool>) -> CachePolicy {
89 match value {
90 None => CachePolicy::Auto,
91 Some(true) => CachePolicy::Always,
92 Some(false) => CachePolicy::Never,
93 }
94 }
95
96 pub fn applies(&self, needed: bool) -> bool {
98 match self {
99 CachePolicy::Auto => needed,
100 CachePolicy::Always => true,
101 CachePolicy::Never => false,
102 }
103 }
104}
105
106impl std::fmt::Display for CachePolicy {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.write_str(self.as_str())
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct UserSpec {
115 pub uid: u32,
116 pub gid: u32,
117 pub name: String,
118 pub group: String,
119}
120
121impl std::fmt::Display for UserSpec {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 write!(f, "{}:{}:{}", self.name, self.uid, self.gid)
125 }
126}
127
128impl UserSpec {
129 const NAME_DEFAULT: &'static str = "app";
131
132 pub fn parse(value: &str) -> Result<UserSpec, Error> {
134 let invalid = || Error::Config {
135 message: format!("invalid --user value `{value}` (expected uid[:gid] or name:uid:gid)"),
136 };
137 let parts: Vec<&str> = value.split(':').collect();
138 match parts.as_slice() {
139 [uid] => {
140 let uid: u32 = uid.parse().map_err(|_| invalid())?;
141 Ok(UserSpec {
142 uid,
143 gid: uid,
144 name: UserSpec::NAME_DEFAULT.to_string(),
145 group: UserSpec::NAME_DEFAULT.to_string(),
146 })
147 }
148 [uid, gid] => {
149 let uid: u32 = uid.parse().map_err(|_| invalid())?;
150 let gid: u32 = gid.parse().map_err(|_| invalid())?;
151 Ok(UserSpec {
152 uid,
153 gid,
154 name: UserSpec::NAME_DEFAULT.to_string(),
155 group: UserSpec::NAME_DEFAULT.to_string(),
156 })
157 }
158 [name, uid, gid] => {
159 let uid: u32 = uid.parse().map_err(|_| invalid())?;
160 let gid: u32 = gid.parse().map_err(|_| invalid())?;
161 if !is_safe_account_name(name) {
162 return Err(invalid());
163 }
164 Ok(UserSpec {
165 uid,
166 gid,
167 name: (*name).to_string(),
168 group: (*name).to_string(),
169 })
170 }
171 _ => Err(invalid()),
172 }
173 }
174}
175
176fn is_safe_account_name(name: &str) -> bool {
179 !name.is_empty()
180 && name.len() <= 32
181 && name
182 .bytes()
183 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct RuntimePolicy {
188 pub ca_certificates: bool,
189 pub tmp: bool,
190 pub passwd_group: bool,
191 pub nsswitch: bool,
192 pub tzdata: bool,
193 pub ld_so_cache: CachePolicy,
196 pub user: Option<UserSpec>,
197 pub includes: Vec<PathBuf>,
198}
199
200impl Default for RuntimePolicy {
201 fn default() -> RuntimePolicy {
202 RuntimePolicy::from_preset(Preset::Minimal)
203 }
204}
205
206impl RuntimePolicy {
207 pub fn from_preset(preset: Preset) -> RuntimePolicy {
208 match preset {
209 Preset::Minimal => RuntimePolicy {
210 ca_certificates: false,
211 tmp: false,
212 passwd_group: false,
213 nsswitch: false,
214 tzdata: false,
215 ld_so_cache: CachePolicy::Auto,
216 user: None,
217 includes: Vec::new(),
218 },
219 Preset::Web => RuntimePolicy {
221 ca_certificates: true,
222 tmp: true,
223 passwd_group: true,
224 nsswitch: true,
225 tzdata: false,
226 ld_so_cache: CachePolicy::Auto,
227 user: None,
228 includes: Vec::new(),
229 },
230 }
231 }
232
233 const UID_ROOT: u32 = 0;
235 const UID_NOBODY: u32 = 65534;
236
237 pub const CA_BUNDLE_CANDIDATES: &'static [&'static str] = &[
239 "/etc/ssl/certs/ca-certificates.crt",
240 "/etc/pki/tls/certs/ca-bundle.crt",
241 "/etc/ssl/ca-bundle.pem",
242 "/etc/ssl/cert.pem",
243 "/usr/local/share/certs/ca-root-nss.crt",
244 ];
245
246 pub const NSS_MODULES: &'static [&'static str] =
250 &["libnss_files.so.2", "libnss_dns.so.2", "libresolv.so.2"];
251
252 pub fn passwd_contents(&self) -> Vec<u8> {
255 let mut out = String::from("root:x:0:0:root:/root:/sbin/nologin\n");
256 out.push_str("nobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin\n");
257 if let Some(user) = &self.user
258 && user.uid != Self::UID_ROOT
259 && user.uid != Self::UID_NOBODY
260 {
261 out.push_str(&format!(
262 "{}:x:{}:{}:{}:/nonexistent:/sbin/nologin\n",
263 user.name, user.uid, user.gid, user.name
264 ));
265 }
266 out.into_bytes()
267 }
268
269 pub fn group_contents(&self) -> Vec<u8> {
270 let mut out = String::from("root:x:0:\n");
271 out.push_str("nogroup:x:65534:\n");
272 if let Some(user) = &self.user
273 && user.gid != Self::UID_ROOT
274 && user.gid != Self::UID_NOBODY
275 {
276 out.push_str(&format!("{}:x:{}:\n", user.group, user.gid));
277 }
278 out.into_bytes()
279 }
280
281 pub fn nsswitch_contents(&self) -> Vec<u8> {
284 let mut out = String::new();
285 out.push_str("# generated by elfpak\n");
286 out.push_str("passwd: files\n");
287 out.push_str("group: files\n");
288 out.push_str("shadow: files\n");
289 out.push_str("hosts: files dns\n");
290 out.push_str("networks: files\n");
291 out.push_str("protocols: files\n");
292 out.push_str("services: files\n");
293 out.into_bytes()
294 }
295}
296
297const _: () = assert!(!RuntimePolicy::CA_BUNDLE_CANDIDATES.is_empty());
300const _: () = assert!(!RuntimePolicy::NSS_MODULES.is_empty());
301const _: () = assert!(RuntimePolicy::UID_ROOT != RuntimePolicy::UID_NOBODY);
302
303#[derive(Debug, Clone, Default, PartialEq, Eq)]
305pub struct DependencyPolicy {
306 pub allow: Option<Vec<String>>,
308}
309
310impl DependencyPolicy {
311 pub fn allow_all() -> DependencyPolicy {
312 DependencyPolicy { allow: None }
313 }
314
315 pub fn allow_list(list: Vec<String>) -> DependencyPolicy {
316 DependencyPolicy { allow: Some(list) }
317 }
318
319 pub fn is_allowed(&self, soname: &str, path: &Path) -> bool {
322 let Some(allow) = &self.allow else {
323 return true;
324 };
325 let file_name = path
326 .file_name()
327 .map(|n| n.to_string_lossy().into_owned())
328 .unwrap_or_default();
329 allow.iter().any(|a| a == soname || *a == file_name)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn presets_are_explicit() {
339 let minimal = RuntimePolicy::from_preset(Preset::Minimal);
340 assert!(!minimal.ca_certificates && !minimal.tmp && !minimal.passwd_group);
341
342 let web = RuntimePolicy::from_preset(Preset::Web);
343 assert!(web.ca_certificates && web.tmp && web.passwd_group && web.nsswitch);
344 assert!(!web.tzdata);
346 }
347
348 #[test]
349 fn user_spec_accepts_the_documented_forms() {
350 assert_eq!(
351 UserSpec::parse("65532").unwrap(),
352 UserSpec {
353 uid: 65532,
354 gid: 65532,
355 name: "app".into(),
356 group: "app".into()
357 }
358 );
359 assert_eq!(UserSpec::parse("65532:1000").unwrap().gid, 1000);
360 assert_eq!(UserSpec::parse("svc:1:2").unwrap().name, "svc");
361 assert!(UserSpec::parse("nobody").is_err());
362 assert!(UserSpec::parse("evil\nroot:1:2").is_err());
363 assert!(UserSpec::parse("bad:name:1:2").is_err());
364 }
365
366 #[test]
367 fn passwd_and_group_include_the_requested_user() {
368 let mut policy = RuntimePolicy::from_preset(Preset::Web);
369 policy.user = Some(UserSpec::parse("65532:65532").unwrap());
370 let passwd = String::from_utf8(policy.passwd_contents()).unwrap();
371 assert!(passwd.contains("app:x:65532:65532"));
372 assert!(passwd.starts_with("root:x:0:0:"));
373 let group = String::from_utf8(policy.group_contents()).unwrap();
374 assert!(group.contains("app:x:65532:"));
375 }
376
377 #[test]
378 fn root_and_nobody_are_not_duplicated() {
379 let mut policy = RuntimePolicy::from_preset(Preset::Web);
380 policy.user = Some(UserSpec::parse("65534:65534").unwrap());
381 let passwd = String::from_utf8(policy.passwd_contents()).unwrap();
382 assert_eq!(passwd.lines().count(), 2);
383 }
384
385 #[test]
386 fn the_cache_is_written_when_the_plan_needs_one() {
387 assert!(!CachePolicy::Auto.applies(false));
388 assert!(CachePolicy::Auto.applies(true));
389 assert!(CachePolicy::Always.applies(false));
390 assert!(!CachePolicy::Never.applies(true));
391
392 assert_eq!(CachePolicy::from_flag(None), CachePolicy::Auto);
393 assert_eq!(CachePolicy::from_flag(Some(true)), CachePolicy::Always);
394 assert_eq!(CachePolicy::from_flag(Some(false)), CachePolicy::Never);
395 assert_eq!(RuntimePolicy::default().ld_so_cache, CachePolicy::Auto);
396 }
397
398 #[test]
399 fn dependency_policy_matches_soname_or_file_name() {
400 let policy = DependencyPolicy::allow_list(vec!["libc.so.6".into()]);
401 assert!(policy.is_allowed("libc.so.6", Path::new("/lib/libc.so.6")));
402 assert!(policy.is_allowed("", Path::new("/lib/libc.so.6")));
403 assert!(!policy.is_allowed("libssl.so.3", Path::new("/lib/libssl.so.3")));
404 assert!(DependencyPolicy::allow_all().is_allowed("anything.so", Path::new("/x")));
405 }
406}