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#[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    /// Canonical `name:uid:gid` form, which [`UserSpec::parse`] round-trips.
123    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    /// Name used when only numeric ids were given; a passwd entry needs one.
130    const NAME_DEFAULT: &'static str = "app";
131
132    /// Accepts `uid`, `uid:gid` and `name:uid:gid`.
133    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
176/// POSIX account names are data embedded in colon-delimited system files.
177/// Restrict them to the portable, non-ambiguous subset before rendering.
178fn 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    /// Generated `/etc/ld.so.cache`. Not a preset choice: the planner decides
194    /// from the closure unless the caller overrides it.
195    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            // Pragmatic server defaults. Timezone data stays opt-in.
220            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    /// Two reserved accounts every image gets, whatever `--user` says.
234    const UID_ROOT: u32 = 0;
235    const UID_NOBODY: u32 = 65534;
236
237    /// Locations of a system CA bundle, most common first.
238    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    /// NSS modules that older glibc versions `dlopen` at runtime. Since glibc
247    /// 2.34 these are built into `libc.so.6`, so they are included only if the
248    /// source root actually provides them.
249    pub const NSS_MODULES: &'static [&'static str] =
250        &["libnss_files.so.2", "libnss_dns.so.2", "libresolv.so.2"];
251
252    /// `/etc/passwd` with root, nobody and, unless it would duplicate one of
253    /// them, the requested user.
254    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    /// `/etc/nsswitch.conf`. Without one, glibc falls back to a built-in
282    /// default that does not include DNS, and the application cannot resolve.
283    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
297// A preset with no CA bundle candidates or no NSS modules to look for would be
298// a policy with nothing to apply.
299const _: () = assert!(!RuntimePolicy::CA_BUNDLE_CANDIDATES.is_empty());
300const _: () = assert!(!RuntimePolicy::NSS_MODULES.is_empty());
301const _: () = assert!(RuntimePolicy::UID_ROOT != RuntimePolicy::UID_NOBODY);
302
303/// Allow-list of shared libraries the build is permitted to depend on.
304#[derive(Debug, Clone, Default, PartialEq, Eq)]
305pub struct DependencyPolicy {
306    /// `None` disables the check entirely; `Some` enforces the list.
307    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    /// A library is identified by its `DT_SONAME` when present, otherwise by
320    /// file name, which is what a user would write on the command line.
321    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        // Timezone data stays opt-in even in the web preset.
345        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}