Skip to main content

elfpak_core/
policy.rs

1//! Runtime policy: everything that ELF analysis cannot prove.
2//!
3//! Presets are configuration only. Every file they contribute shows up in the
4//! bundle plan with a policy reason attached.
5
6use 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/// Whether the bundle gets a generated `/etc/ld.so.cache`.
64///
65/// The loader searches a fixed set of directories plus whatever the objects
66/// themselves declare; everything else it knows comes from the cache, and a
67/// bundle has no `ldconfig` to build one. [`CachePolicy::Auto`] therefore
68/// writes a cache exactly when the plan contains something the loader would
69/// otherwise fail to find.
70#[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    /// `--ld-so-cache[=BOOL]`: absent leaves the decision to the planner.
88    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    /// Whether to write a cache, given whether the plan needs one.
97    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/// The identity the packaged application is expected to run as.
113///
114/// The fields are private because the name is rendered verbatim into
115/// `/etc/passwd` and `/etc/group`, which are colon- and newline-delimited. A
116/// value that reached those files unchecked could declare a second account —
117/// including a uid-0 one with a shell — in an image whose whole point is that
118/// it contains nothing unaudited. Construct one with [`UserSpec::parse`] or
119/// [`UserSpec::new`], both of which enforce that invariant.
120#[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    /// Canonical `name:uid:gid` form, which [`UserSpec::parse`] round-trips.
130    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    /// Name used when only numeric ids were given; a passwd entry needs one.
137    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    /// A checked identity. The name must be a portable account name, and must
156    /// not contradict the two accounts every image already has.
157    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    /// Accepts `uid`, `uid:gid` and `name:uid:gid`.
176    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        // A caller that gave only numbers named no account, so naming one is
190        // this function's job: `--user 65534:65534` means `nobody`, and calling
191        // it `app` would be inventing a second account for those ids.
192        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            // Keep the option in the message; `new` does not know it exists.
196            Error::Config { message } => Error::Config {
197                message: format!("invalid --user value `{value}`: {message}"),
198            },
199            other => other,
200        })
201    }
202}
203
204/// Every image already has `root` and `nobody`. A requested account that reuses
205/// one of their names or ids without being that account would put two entries
206/// with one name, or one id, into `/etc/passwd`; `getpwnam` and `getpwuid` then
207/// disagree with the identity the process actually runs as.
208fn 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            // Being one of these accounts is fine; redefining it is not.
213            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        // A reserved *uid* under another name would leave the requested
221        // account out of `/etc/passwd` entirely, since that file already has an
222        // entry for the id. A reserved *gid* is fine: `/etc/group` already has
223        // that group, and the passwd entry simply refers to it.
224        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
235/// Accounts every generated `/etc/passwd` and `/etc/group` already contains,
236/// as `(account, group, id)`.
237const RESERVED_ACCOUNTS: &[(&str, &str, u32)] = &[
238    ("root", "root", RuntimePolicy::UID_ROOT),
239    ("nobody", "nogroup", RuntimePolicy::UID_NOBODY),
240];
241
242/// The reserved account a bare `uid[:gid]` names, if it names one.
243fn 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
250/// POSIX account names are data embedded in colon-delimited system files.
251/// Restrict them to the portable, non-ambiguous subset before rendering.
252fn 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    /// Generated `/etc/ld.so.cache`. Not a preset choice: the planner decides
268    /// from the closure unless the caller overrides it.
269    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            // Pragmatic server defaults. Timezone data stays opt-in.
294            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    /// Two reserved accounts every image gets, whatever `--user` says.
308    pub(crate) const UID_ROOT: u32 = 0;
309    pub(crate) const UID_NOBODY: u32 = 65534;
310
311    /// Locations of a system CA bundle, most common first.
312    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    /// NSS modules that older glibc versions `dlopen` at runtime. Since glibc
321    /// 2.34 these are built into `libc.so.6`, so they are included only if the
322    /// source root actually provides them.
323    pub const NSS_MODULES: &'static [&'static str] =
324        &["libnss_files.so.2", "libnss_dns.so.2", "libresolv.so.2"];
325
326    /// `/etc/passwd` with root, nobody and, unless it would duplicate one of
327    /// them, the requested user.
328    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        // `UserSpec` refuses any identity that would collide with the two
332        // accounts above, so the only way to reach one of their ids here is by
333        // asking for that account itself, which is already written.
334        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    /// `/etc/nsswitch.conf`. Without one, glibc falls back to a built-in
362    /// default that does not include DNS, and the application cannot resolve.
363    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
377// A preset with no CA bundle candidates or no NSS modules to look for would be
378// a policy with nothing to apply.
379const _: () = assert!(!RuntimePolicy::CA_BUNDLE_CANDIDATES.is_empty());
380const _: () = assert!(!RuntimePolicy::NSS_MODULES.is_empty());
381const _: () = assert!(RuntimePolicy::UID_ROOT != RuntimePolicy::UID_NOBODY);
382
383/// Allow-list of shared libraries the build is permitted to depend on.
384#[derive(Debug, Clone, Default, PartialEq, Eq)]
385pub struct DependencyPolicy {
386    /// `None` disables the check entirely; `Some` enforces the list.
387    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    /// A library is identified by its `DT_SONAME` when present, otherwise by
400    /// file name, which is what a user would write on the command line.
401    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        // Timezone data stays opt-in even in the web preset.
425        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    /// An identity is rendered into colon- and newline-delimited system files,
466    /// so the type refuses anything that could add a line of its own.
467    #[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    /// Reusing a reserved name or id would put two accounts with one name, or
477    /// one id, into the generated files.
478    #[test]
479    fn reserved_accounts_cannot_be_redefined() {
480        // Reusing a reserved name, or a reserved uid under another name.
481        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        // A reserved *group* id under another name is ordinary: the group
488        // already exists and the account simply joins it.
489        assert_eq!(UserSpec::parse("1000:65534").unwrap().gid(), 65534);
490        assert_eq!(UserSpec::parse("app:1000:0").unwrap().gid(), 0);
491    }
492
493    /// A bare `uid[:gid]` names no account, so one that matches a reserved id
494    /// exactly *is* that account rather than a second one under a made-up name.
495    #[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        // Being a reserved account adds no second entry to either file.
503        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}