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)]
121pub struct UserSpec {
122 uid: u32,
123 gid: u32,
124 name: String,
125 group: String,
126}
127
128impl std::fmt::Display for UserSpec {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 write!(f, "{}:{}:{}", self.name, self.uid, self.gid)
132 }
133}
134
135impl UserSpec {
136 const NAME_DEFAULT: &'static str = "app";
138
139 pub fn uid(&self) -> u32 {
140 self.uid
141 }
142
143 pub fn gid(&self) -> u32 {
144 self.gid
145 }
146
147 pub fn name(&self) -> &str {
148 &self.name
149 }
150
151 pub fn group(&self) -> &str {
152 &self.group
153 }
154
155 pub fn new(name: &str, uid: u32, gid: u32) -> Result<UserSpec, Error> {
158 if !is_safe_account_name(name) {
159 return Err(Error::Config {
160 message: format!(
161 "invalid account name `{name}` \
162 (1-32 characters of A-Z, a-z, 0-9, `_` or `-`)"
163 ),
164 });
165 }
166 check_reserved_account(name, uid, gid)?;
167 Ok(UserSpec {
168 uid,
169 gid,
170 name: name.to_string(),
171 group: name.to_string(),
172 })
173 }
174
175 pub fn parse(value: &str) -> Result<UserSpec, Error> {
177 let invalid = || Error::Config {
178 message: format!("invalid --user value `{value}` (expected uid[:gid] or name:uid:gid)"),
179 };
180 let number = |text: &str| text.parse::<u32>().map_err(|_| invalid());
181
182 let parts: Vec<&str> = value.split(':').collect();
183 let (name, uid, gid) = match parts.as_slice() {
184 [uid] => (None, number(uid)?, number(uid)?),
185 [uid, gid] => (None, number(uid)?, number(gid)?),
186 [name, uid, gid] => (Some(*name), number(uid)?, number(gid)?),
187 _ => return Err(invalid()),
188 };
189 let name =
193 name.unwrap_or_else(|| reserved_name(uid, gid).unwrap_or(UserSpec::NAME_DEFAULT));
194 UserSpec::new(name, uid, gid).map_err(|error| match error {
195 Error::Config { message } => Error::Config {
197 message: format!("invalid --user value `{value}`: {message}"),
198 },
199 other => other,
200 })
201 }
202}
203
204fn check_reserved_account(name: &str, uid: u32, gid: u32) -> Result<(), Error> {
209 for (account, group, id) in RESERVED_ACCOUNTS {
210 let named = name == *account || name == *group;
211 if named {
212 if uid == *id && gid == *id {
214 return Ok(());
215 }
216 return Err(Error::Config {
217 message: format!("`{name}` is the reserved account {account}:{id}:{id}"),
218 });
219 }
220 if uid == *id {
225 return Err(Error::Config {
226 message: format!(
227 "uid {id} belongs to the reserved account `{account}`, not to `{name}`"
228 ),
229 });
230 }
231 }
232 Ok(())
233}
234
235const RESERVED_ACCOUNTS: &[(&str, &str, u32)] = &[
238 ("root", "root", RuntimePolicy::UID_ROOT),
239 ("nobody", "nogroup", RuntimePolicy::UID_NOBODY),
240];
241
242fn reserved_name(uid: u32, gid: u32) -> Option<&'static str> {
244 RESERVED_ACCOUNTS
245 .iter()
246 .find(|(_, _, id)| uid == *id && gid == *id)
247 .map(|(account, _, _)| *account)
248}
249
250fn is_safe_account_name(name: &str) -> bool {
253 !name.is_empty()
254 && name.len() <= 32
255 && name
256 .bytes()
257 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct RuntimePolicy {
262 pub ca_certificates: bool,
263 pub tmp: bool,
264 pub passwd_group: bool,
265 pub nsswitch: bool,
266 pub tzdata: bool,
267 pub ld_so_cache: CachePolicy,
270 pub user: Option<UserSpec>,
271 pub includes: Vec<PathBuf>,
272}
273
274impl Default for RuntimePolicy {
275 fn default() -> RuntimePolicy {
276 RuntimePolicy::from_preset(Preset::Minimal)
277 }
278}
279
280impl RuntimePolicy {
281 pub fn from_preset(preset: Preset) -> RuntimePolicy {
282 match preset {
283 Preset::Minimal => RuntimePolicy {
284 ca_certificates: false,
285 tmp: false,
286 passwd_group: false,
287 nsswitch: false,
288 tzdata: false,
289 ld_so_cache: CachePolicy::Auto,
290 user: None,
291 includes: Vec::new(),
292 },
293 Preset::Web => RuntimePolicy {
295 ca_certificates: true,
296 tmp: true,
297 passwd_group: true,
298 nsswitch: true,
299 tzdata: false,
300 ld_so_cache: CachePolicy::Auto,
301 user: None,
302 includes: Vec::new(),
303 },
304 }
305 }
306
307 pub(crate) const UID_ROOT: u32 = 0;
309 pub(crate) const UID_NOBODY: u32 = 65534;
310
311 pub const CA_BUNDLE_CANDIDATES: &'static [&'static str] = &[
313 "/etc/ssl/certs/ca-certificates.crt",
314 "/etc/pki/tls/certs/ca-bundle.crt",
315 "/etc/ssl/ca-bundle.pem",
316 "/etc/ssl/cert.pem",
317 "/usr/local/share/certs/ca-root-nss.crt",
318 ];
319
320 pub const NSS_MODULES: &'static [&'static str] =
324 &["libnss_files.so.2", "libnss_dns.so.2", "libresolv.so.2"];
325
326 pub fn passwd_contents(&self) -> Vec<u8> {
329 let mut out = String::from("root:x:0:0:root:/root:/sbin/nologin\n");
330 out.push_str("nobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin\n");
331 if let Some(user) = &self.user
335 && user.uid() != Self::UID_ROOT
336 && user.uid() != Self::UID_NOBODY
337 {
338 out.push_str(&format!(
339 "{}:x:{}:{}:{}:/nonexistent:/sbin/nologin\n",
340 user.name(),
341 user.uid(),
342 user.gid(),
343 user.name()
344 ));
345 }
346 out.into_bytes()
347 }
348
349 pub fn group_contents(&self) -> Vec<u8> {
350 let mut out = String::from("root:x:0:\n");
351 out.push_str("nogroup:x:65534:\n");
352 if let Some(user) = &self.user
353 && user.gid() != Self::UID_ROOT
354 && user.gid() != Self::UID_NOBODY
355 {
356 out.push_str(&format!("{}:x:{}:\n", user.group(), user.gid()));
357 }
358 out.into_bytes()
359 }
360
361 pub fn nsswitch_contents(&self) -> Vec<u8> {
364 let mut out = String::new();
365 out.push_str("# generated by elfpak\n");
366 out.push_str("passwd: files\n");
367 out.push_str("group: files\n");
368 out.push_str("shadow: files\n");
369 out.push_str("hosts: files dns\n");
370 out.push_str("networks: files\n");
371 out.push_str("protocols: files\n");
372 out.push_str("services: files\n");
373 out.into_bytes()
374 }
375}
376
377const _: () = assert!(!RuntimePolicy::CA_BUNDLE_CANDIDATES.is_empty());
380const _: () = assert!(!RuntimePolicy::NSS_MODULES.is_empty());
381const _: () = assert!(RuntimePolicy::UID_ROOT != RuntimePolicy::UID_NOBODY);
382
383#[derive(Debug, Clone, Default, PartialEq, Eq)]
385pub struct DependencyPolicy {
386 pub allow: Option<Vec<String>>,
388}
389
390impl DependencyPolicy {
391 pub fn allow_all() -> DependencyPolicy {
392 DependencyPolicy { allow: None }
393 }
394
395 pub fn allow_list(list: Vec<String>) -> DependencyPolicy {
396 DependencyPolicy { allow: Some(list) }
397 }
398
399 pub fn is_allowed(&self, soname: &str, path: &Path) -> bool {
402 let Some(allow) = &self.allow else {
403 return true;
404 };
405 let file_name = path
406 .file_name()
407 .map(|n| n.to_string_lossy().into_owned())
408 .unwrap_or_default();
409 allow.iter().any(|a| a == soname || *a == file_name)
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn presets_are_explicit() {
419 let minimal = RuntimePolicy::from_preset(Preset::Minimal);
420 assert!(!minimal.ca_certificates && !minimal.tmp && !minimal.passwd_group);
421
422 let web = RuntimePolicy::from_preset(Preset::Web);
423 assert!(web.ca_certificates && web.tmp && web.passwd_group && web.nsswitch);
424 assert!(!web.tzdata);
426 }
427
428 #[test]
429 fn user_spec_accepts_the_documented_forms() {
430 assert_eq!(
431 UserSpec::parse("65532").unwrap(),
432 UserSpec {
433 uid: 65532,
434 gid: 65532,
435 name: "app".into(),
436 group: "app".into()
437 }
438 );
439 assert_eq!(UserSpec::parse("65532:1000").unwrap().gid, 1000);
440 assert_eq!(UserSpec::parse("svc:1:2").unwrap().name, "svc");
441 assert!(UserSpec::parse("nobody").is_err());
442 assert!(UserSpec::parse("evil\nroot:1:2").is_err());
443 assert!(UserSpec::parse("bad:name:1:2").is_err());
444 }
445
446 #[test]
447 fn passwd_and_group_include_the_requested_user() {
448 let mut policy = RuntimePolicy::from_preset(Preset::Web);
449 policy.user = Some(UserSpec::parse("65532:65532").unwrap());
450 let passwd = String::from_utf8(policy.passwd_contents()).unwrap();
451 assert!(passwd.contains("app:x:65532:65532"));
452 assert!(passwd.starts_with("root:x:0:0:"));
453 let group = String::from_utf8(policy.group_contents()).unwrap();
454 assert!(group.contains("app:x:65532:"));
455 }
456
457 #[test]
458 fn root_and_nobody_are_not_duplicated() {
459 let mut policy = RuntimePolicy::from_preset(Preset::Web);
460 policy.user = Some(UserSpec::parse("nobody:65534:65534").unwrap());
461 let passwd = String::from_utf8(policy.passwd_contents()).unwrap();
462 assert_eq!(passwd.lines().count(), 2);
463 }
464
465 #[test]
468 fn an_account_name_cannot_carry_passwd_syntax() {
469 assert!(UserSpec::new("svc:x:0:0::/root:/bin/sh\nbackdoor", 1000, 1000).is_err());
470 assert!(UserSpec::new("with space", 1000, 1000).is_err());
471 assert!(UserSpec::new("", 1000, 1000).is_err());
472 assert!(UserSpec::new(&"a".repeat(33), 1000, 1000).is_err());
473 assert!(UserSpec::new("svc-1_x", 1000, 1000).is_ok());
474 }
475
476 #[test]
479 fn reserved_accounts_cannot_be_redefined() {
480 assert!(UserSpec::parse("root:1000:1000").is_err());
482 assert!(UserSpec::parse("nobody:1000:1000").is_err());
483 assert!(UserSpec::parse("nogroup:1000:1000").is_err());
484 assert!(UserSpec::parse("app:0:0").is_err());
485 assert!(UserSpec::parse("app:65534:65534").is_err());
486
487 assert_eq!(UserSpec::parse("1000:65534").unwrap().gid(), 65534);
490 assert_eq!(UserSpec::parse("app:1000:0").unwrap().gid(), 0);
491 }
492
493 #[test]
496 fn numeric_identities_adopt_the_reserved_name_they_match() {
497 assert_eq!(UserSpec::parse("65534:65534").unwrap().name(), "nobody");
498 assert_eq!(UserSpec::parse("65534").unwrap().name(), "nobody");
499 assert_eq!(UserSpec::parse("0").unwrap().name(), "root");
500 assert_eq!(UserSpec::parse("65532:65532").unwrap().name(), "app");
501
502 let mut policy = RuntimePolicy::from_preset(Preset::Web);
504 policy.user = Some(UserSpec::parse("0").unwrap());
505 let passwd = String::from_utf8(policy.passwd_contents()).unwrap();
506 assert_eq!(passwd.lines().count(), 2);
507 assert_eq!(
508 String::from_utf8(policy.group_contents())
509 .unwrap()
510 .lines()
511 .count(),
512 2
513 );
514 }
515
516 #[test]
517 fn the_cache_is_written_when_the_plan_needs_one() {
518 assert!(!CachePolicy::Auto.applies(false));
519 assert!(CachePolicy::Auto.applies(true));
520 assert!(CachePolicy::Always.applies(false));
521 assert!(!CachePolicy::Never.applies(true));
522
523 assert_eq!(CachePolicy::from_flag(None), CachePolicy::Auto);
524 assert_eq!(CachePolicy::from_flag(Some(true)), CachePolicy::Always);
525 assert_eq!(CachePolicy::from_flag(Some(false)), CachePolicy::Never);
526 assert_eq!(RuntimePolicy::default().ld_so_cache, CachePolicy::Auto);
527 }
528
529 #[test]
530 fn dependency_policy_matches_soname_or_file_name() {
531 let policy = DependencyPolicy::allow_list(vec!["libc.so.6".into()]);
532 assert!(policy.is_allowed("libc.so.6", Path::new("/lib/libc.so.6")));
533 assert!(policy.is_allowed("", Path::new("/lib/libc.so.6")));
534 assert!(!policy.is_allowed("libssl.so.3", Path::new("/lib/libssl.so.3")));
535 assert!(DependencyPolicy::allow_all().is_allowed("anything.so", Path::new("/x")));
536 }
537}