Skip to main content

ferroday_cage/
limits.rs

1//! Per-process resource limits applied to the sandboxed command.
2//!
3//! The sandbox's namespaces bound what the command can *reach*; a resource
4//! limit bounds what it can *consume*. The two are independent: a command
5//! confined to a private root with no network can still fork without end,
6//! allocate without end, and fill the filesystem it was given.
7//!
8//! A limit is set with [`CageBuilder::rlimit`](crate::CageBuilder::rlimit) and
9//! is applied to the command process immediately before the hardening layer, so
10//! the command and every process it starts inherit it. Limits are the kernel's
11//! own `setrlimit` values with the kernel's own semantics — see
12//! [`Resource`] for the caveats that matter in a sandbox.
13//!
14#![cfg_attr(
15    feature = "hardening",
16    doc = "
17The restriction fallback carries the same knob as
18[`RestrictionBuilder::rlimit`](crate::RestrictionBuilder::rlimit).
19
20"
21)]
22//! With the `serde` feature a cage's limits are part of the profile format, as
23//! an `[rlimit]` table keyed by resource name:
24//!
25//! ```toml
26//! [rlimit]
27//! processes = 64                                # soft and hard alike
28//! address-space = 536870912
29//! open-files = "unlimited"
30//! cpu-time = { soft = 10, hard = "unlimited" }  # when the two differ
31//! ```
32//!
33//! Amounts there are TOML integers, so a profile expresses limits up to
34//! `i64::MAX`. Nothing a real resource is counted in reaches that; a limit
35//! meant to be absent is `"unlimited"` rather than a very large number.
36//!
37//! Resource *governance* is a different thing and remains outside this
38//! library: cgroup controllers, disk quotas, and I/O throttling are the host's
39//! to configure. What is here is the per-process axis, which needs no
40//! privilege and no host configuration.
41
42use std::fmt;
43
44/// The spelling both the profile format and the command line use for
45/// [`Limit::UNLIMITED`].
46const UNLIMITED_NAME: &str = "unlimited";
47
48/// A resource limit value: a finite amount, or no limit at all.
49///
50/// Constructed from a `u64` — `.rlimit(Resource::Processes, 64, 64)` — or as
51/// [`UNLIMITED`](Self::UNLIMITED) for the kernel's `RLIM_INFINITY`.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub struct Limit(Option<u64>);
54
55/// Orders limits by how much they permit, so `UNLIMITED` is the maximum.
56///
57/// Not derived: the inner `Option` orders `None` *below* every `Some`, which
58/// would make `Limit::UNLIMITED < Limit::of(0)` — the inverse of what a limit
59/// means and of what this type documents.
60impl Ord for Limit {
61    fn cmp(&self, other: &Limit) -> std::cmp::Ordering {
62        match (self.0, other.0) {
63            (Some(left), Some(right)) => left.cmp(&right),
64            (None, None) => std::cmp::Ordering::Equal,
65            (None, Some(_)) => std::cmp::Ordering::Greater,
66            (Some(_), None) => std::cmp::Ordering::Less,
67        }
68    }
69}
70
71impl PartialOrd for Limit {
72    fn partial_cmp(&self, other: &Limit) -> Option<std::cmp::Ordering> {
73        Some(self.cmp(other))
74    }
75}
76
77impl Limit {
78    /// No limit: the kernel's `RLIM_INFINITY`.
79    ///
80    /// Ordered above every finite limit, so a finite soft limit under an
81    /// unlimited hard limit is valid.
82    pub const UNLIMITED: Limit = Limit(None);
83
84    /// A finite limit, in the resource's own unit.
85    ///
86    /// `u64::MAX` is the kernel's `RLIM_INFINITY` rather than an amount, so it
87    /// yields [`UNLIMITED`](Self::UNLIMITED): there is no finite limit the
88    /// kernel would distinguish from no limit at all.
89    pub const fn of(amount: u64) -> Limit {
90        if amount == u64::MAX {
91            Limit::UNLIMITED
92        } else {
93            Limit(Some(amount))
94        }
95    }
96
97    /// The finite amount, or `None` when the limit is
98    /// [`UNLIMITED`](Self::UNLIMITED).
99    pub const fn amount(self) -> Option<u64> {
100        self.0
101    }
102
103    /// Whether this limit is at most `other`, with unlimited as the maximum.
104    pub(crate) fn fits_within(self, other: Limit) -> bool {
105        match (self.0, other.0) {
106            (_, None) => true,
107            (None, Some(_)) => false,
108            (Some(soft), Some(hard)) => soft <= hard,
109        }
110    }
111}
112
113impl From<u64> for Limit {
114    fn from(amount: u64) -> Limit {
115        Limit::of(amount)
116    }
117}
118
119impl fmt::Display for Limit {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self.0 {
122            Some(amount) => write!(f, "{amount}"),
123            None => f.write_str(UNLIMITED_NAME),
124        }
125    }
126}
127
128/// A limit renders as its bare amount, or as the string `"unlimited"`.
129#[cfg(feature = "serde")]
130impl serde::Serialize for Limit {
131    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
132        match self.0 {
133            Some(amount) => serializer.serialize_u64(amount),
134            None => serializer.serialize_str(UNLIMITED_NAME),
135        }
136    }
137}
138
139/// Accepts either form a limit renders as, in a self-describing format.
140#[cfg(feature = "serde")]
141impl<'de> serde::Deserialize<'de> for Limit {
142    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
143        deserializer.deserialize_any(LimitVisitor)
144    }
145}
146
147#[cfg(feature = "serde")]
148struct LimitVisitor;
149
150#[cfg(feature = "serde")]
151impl serde::de::Visitor<'_> for LimitVisitor {
152    type Value = Limit;
153
154    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(f, "a non-negative amount or {UNLIMITED_NAME:?}")
156    }
157
158    fn visit_u64<E: serde::de::Error>(self, amount: u64) -> Result<Limit, E> {
159        Ok(Limit::of(amount))
160    }
161
162    // TOML integers are signed, so a profile's amounts arrive here.
163    fn visit_i64<E: serde::de::Error>(self, amount: i64) -> Result<Limit, E> {
164        u64::try_from(amount)
165            .map(Limit::of)
166            .map_err(|_| E::custom(format!("a resource limit cannot be negative: {amount}")))
167    }
168
169    fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Limit, E> {
170        if text == UNLIMITED_NAME {
171            Ok(Limit::UNLIMITED)
172        } else {
173            Err(E::custom(format!(
174                "expected an amount or {UNLIMITED_NAME:?}, not {text:?}"
175            )))
176        }
177    }
178}
179
180/// A per-process resource whose limit the launch sets on the command.
181///
182/// Each variant names the kernel's `RLIMIT_*` resource it sets and the unit
183/// its values are counted in. Two carry sandbox-specific caveats:
184///
185/// - [`Processes`](Self::Processes) is the kernel's `RLIMIT_NPROC`, which
186///   counts processes per *real user id* across the whole system, not per
187///   namespace or per sandbox. Under the single-identity map the command's
188///   real uid outside the sandbox is the calling user's, so the limit counts
189///   the caller's other processes too, and the cap is a ceiling on the pair
190///   rather than on the sandbox alone. A range identity map gives the sandbox
191///   distinct host ids, so the count is the sandbox's own.
192/// - [`AddressSpace`](Self::AddressSpace) counts mapped address space, not
193///   resident memory, so a command that maps far more than it touches — many
194///   runtimes and allocators do — hits it long before its real memory use
195///   approaches the value.
196///
197/// The kebab-case spelling of each variant is the name a profile's `[rlimit]`
198/// table and the command line's `--rlimit` flag both use: `address-space`,
199/// `cpu-time`, `open-files`, and so on.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
201#[cfg_attr(
202    feature = "serde",
203    derive(serde::Serialize, serde::Deserialize),
204    serde(rename_all = "kebab-case")
205)]
206#[non_exhaustive]
207pub enum Resource {
208    /// `RLIMIT_AS`: the total mapped address space, in bytes.
209    AddressSpace,
210    /// `RLIMIT_CORE`: the size of a core dump, in bytes.
211    CoreDump,
212    /// `RLIMIT_CPU`: CPU time consumed, in seconds. Exceeding the soft limit
213    /// raises `SIGXCPU`; exceeding the hard limit is fatal.
214    CpuTime,
215    /// `RLIMIT_DATA`: the data segment — the heap and anonymous mappings — in
216    /// bytes.
217    Data,
218    /// `RLIMIT_FSIZE`: the largest file the command may create, in bytes. A
219    /// write beyond it raises `SIGXFSZ`.
220    FileSize,
221    /// `RLIMIT_MEMLOCK`: memory that may be locked into RAM, in bytes.
222    LockedMemory,
223    /// `RLIMIT_NOFILE`: one greater than the highest file descriptor the
224    /// command may open.
225    OpenFiles,
226    /// `RLIMIT_SIGPENDING`: signals that may be queued for the command's real
227    /// user id.
228    PendingSignals,
229    /// `RLIMIT_NPROC`: processes and threads for the command's real user id.
230    /// The fork-bomb ceiling; see the caveat above.
231    Processes,
232    /// `RLIMIT_STACK`: the main thread's stack, in bytes.
233    Stack,
234}
235
236impl Resource {
237    /// Every resource a limit can be set on, in the order they are documented.
238    ///
239    /// The roster grows as the kernel's does, so this is a slice rather than a
240    /// fixed-length array: a consumer that presents the resources — a help
241    /// text, a form, a completion script — picks up an addition without a
242    /// change. Pair it with [`spelling`](Self::spelling) for the names a
243    /// profile and the command line accept.
244    pub const ALL: &'static [Resource] = &[
245        Resource::AddressSpace,
246        Resource::CoreDump,
247        Resource::CpuTime,
248        Resource::Data,
249        Resource::FileSize,
250        Resource::LockedMemory,
251        Resource::OpenFiles,
252        Resource::PendingSignals,
253        Resource::Processes,
254        Resource::Stack,
255    ];
256
257    /// The name a profile's `[rlimit]` table and the `--rlimit` flag spell this
258    /// resource with: the kebab-case form of the variant.
259    ///
260    /// Distinct from [`Display`](std::fmt::Display), which renders the kernel's
261    /// own `RLIMIT_*` name because that is what an error about the kernel's
262    /// refusal should say.
263    pub fn spelling(self) -> &'static str {
264        match self {
265            Resource::AddressSpace => "address-space",
266            Resource::CoreDump => "core-dump",
267            Resource::CpuTime => "cpu-time",
268            Resource::Data => "data",
269            Resource::FileSize => "file-size",
270            Resource::LockedMemory => "locked-memory",
271            Resource::OpenFiles => "open-files",
272            Resource::PendingSignals => "pending-signals",
273            Resource::Processes => "processes",
274            Resource::Stack => "stack",
275        }
276    }
277
278    /// The kernel resource this names.
279    pub(crate) fn to_kernel(self) -> rustix::process::Resource {
280        use rustix::process::Resource as Kernel;
281        match self {
282            Resource::AddressSpace => Kernel::As,
283            Resource::CoreDump => Kernel::Core,
284            Resource::CpuTime => Kernel::Cpu,
285            Resource::Data => Kernel::Data,
286            Resource::FileSize => Kernel::Fsize,
287            Resource::LockedMemory => Kernel::Memlock,
288            Resource::OpenFiles => Kernel::Nofile,
289            Resource::PendingSignals => Kernel::Sigpending,
290            Resource::Processes => Kernel::Nproc,
291            Resource::Stack => Kernel::Stack,
292        }
293    }
294
295    /// The kernel's own name for the resource, for error messages.
296    pub(crate) fn kernel_name(self) -> &'static str {
297        match self {
298            Resource::AddressSpace => "RLIMIT_AS",
299            Resource::CoreDump => "RLIMIT_CORE",
300            Resource::CpuTime => "RLIMIT_CPU",
301            Resource::Data => "RLIMIT_DATA",
302            Resource::FileSize => "RLIMIT_FSIZE",
303            Resource::LockedMemory => "RLIMIT_MEMLOCK",
304            Resource::OpenFiles => "RLIMIT_NOFILE",
305            Resource::PendingSignals => "RLIMIT_SIGPENDING",
306            Resource::Processes => "RLIMIT_NPROC",
307            Resource::Stack => "RLIMIT_STACK",
308        }
309    }
310}
311
312impl fmt::Display for Resource {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        f.write_str(self.kernel_name())
315    }
316}
317
318/// Serde adapter for a builder's resource-limit map, as the profile's
319/// `[rlimit]` table.
320///
321/// Each entry is keyed by the resource's kebab-case name. Its value is the
322/// shorthand — a bare [`Limit`] standing for both the soft and the hard limit
323/// — whenever the two agree, and the explicit `{ soft = …, hard = … }` table
324/// when they differ. Both forms are accepted on the way in whether or not the
325/// values agree, so a hand-written profile may always spell out the pair.
326#[cfg(feature = "serde")]
327pub(crate) mod serde_rlimits {
328    use std::fmt;
329
330    use super::{Limit, Resource};
331    use serde::Deserialize as _;
332    use serde::de::{Error as _, MapAccess, Visitor};
333    use serde::ser::SerializeMap;
334    use std::collections::BTreeMap;
335
336    /// The explicit form, for a resource whose soft and hard limits differ.
337    #[derive(serde::Serialize)]
338    struct Pair {
339        soft: Limit,
340        hard: Limit,
341    }
342
343    pub(crate) fn serialize<S: serde::Serializer>(
344        rlimits: &BTreeMap<Resource, (Limit, Limit)>,
345        serializer: S,
346    ) -> Result<S::Ok, S::Error> {
347        let mut map = serializer.serialize_map(Some(rlimits.len()))?;
348        for (resource, &(soft, hard)) in rlimits {
349            if soft == hard {
350                map.serialize_entry(resource, &soft)?;
351            } else {
352                map.serialize_entry(resource, &Pair { soft, hard })?;
353            }
354        }
355        map.end()
356    }
357
358    pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>(
359        deserializer: D,
360    ) -> Result<BTreeMap<Resource, (Limit, Limit)>, D::Error> {
361        let settings = BTreeMap::<Resource, Setting>::deserialize(deserializer)?;
362        Ok(settings
363            .into_iter()
364            .map(|(resource, Setting(soft, hard))| (resource, (soft, hard)))
365            .collect())
366    }
367
368    /// One entry's value: a soft and a hard limit, however it was spelled.
369    struct Setting(Limit, Limit);
370
371    impl<'de> serde::Deserialize<'de> for Setting {
372        fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
373            deserializer.deserialize_any(SettingVisitor)
374        }
375    }
376
377    struct SettingVisitor;
378
379    impl<'de> Visitor<'de> for SettingVisitor {
380        type Value = Setting;
381
382        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383            f.write_str(
384                "a resource limit: one value for both the soft and the hard limit, \
385                 or a table of `soft` and `hard`",
386            )
387        }
388
389        // The shorthand: one value standing for both limits. Delegating to
390        // `Limit` keeps the accepted spellings identical in both positions.
391        fn visit_u64<E: serde::de::Error>(self, amount: u64) -> Result<Setting, E> {
392            let limit = super::LimitVisitor.visit_u64(amount)?;
393            Ok(Setting(limit, limit))
394        }
395
396        fn visit_i64<E: serde::de::Error>(self, amount: i64) -> Result<Setting, E> {
397            let limit = super::LimitVisitor.visit_i64(amount)?;
398            Ok(Setting(limit, limit))
399        }
400
401        fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Setting, E> {
402            let limit = super::LimitVisitor.visit_str(text)?;
403            Ok(Setting(limit, limit))
404        }
405
406        fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Setting, M::Error> {
407            let (mut soft, mut hard) = (None, None);
408            while let Some(key) = map.next_key::<String>()? {
409                let slot = match key.as_str() {
410                    "soft" => &mut soft,
411                    "hard" => &mut hard,
412                    other => return Err(M::Error::unknown_field(other, &["soft", "hard"])),
413                };
414                if slot.is_some() {
415                    return Err(M::Error::custom(format!("duplicate field `{key}`")));
416                }
417                *slot = Some(map.next_value::<Limit>()?);
418            }
419            Ok(Setting(
420                soft.ok_or_else(|| M::Error::missing_field("soft"))?,
421                hard.ok_or_else(|| M::Error::missing_field("hard"))?,
422            ))
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use std::cmp::Ordering;
430
431    use super::*;
432
433    #[test]
434    fn the_comparison_operators_agree_with_fits_within() {
435        // The derived ordering on the inner `Option` would put `UNLIMITED`
436        // below every finite limit, so a consumer sorting or clamping limits
437        // through the public operators would get the inverse of what a limit
438        // means. The two answers must not disagree.
439        assert!(Limit::UNLIMITED > Limit::of(0));
440        assert!(Limit::UNLIMITED > Limit::of(u64::MAX - 1));
441        assert!(Limit::of(1) < Limit::of(2));
442        assert_eq!(Limit::UNLIMITED.cmp(&Limit::UNLIMITED), Ordering::Equal);
443        for (soft, hard) in [
444            (Limit::of(1), Limit::UNLIMITED),
445            (Limit::UNLIMITED, Limit::UNLIMITED),
446            (Limit::UNLIMITED, Limit::of(u64::MAX - 1)),
447            (Limit::of(2), Limit::of(1)),
448            (Limit::of(1), Limit::of(2)),
449        ] {
450            assert_eq!(
451                soft.fits_within(hard),
452                soft <= hard,
453                "{soft} within {hard} disagrees with the ordering",
454            );
455        }
456    }
457
458    #[test]
459    fn unlimited_is_the_ceiling_in_both_directions() {
460        assert!(Limit::of(1).fits_within(Limit::UNLIMITED));
461        assert!(Limit::UNLIMITED.fits_within(Limit::UNLIMITED));
462        // `u64::MAX` is the kernel's infinity rather than an amount, so the
463        // largest finite limit is one below it.
464        assert!(!Limit::UNLIMITED.fits_within(Limit::of(u64::MAX - 1)));
465    }
466
467    #[test]
468    fn a_finite_soft_limit_must_not_exceed_its_hard_limit() {
469        assert!(Limit::of(4).fits_within(Limit::of(4)));
470        assert!(Limit::of(4).fits_within(Limit::of(5)));
471        assert!(!Limit::of(5).fits_within(Limit::of(4)));
472    }
473
474    #[test]
475    fn a_limit_renders_its_amount_or_the_word() {
476        assert_eq!(Limit::of(64).to_string(), "64");
477        assert_eq!(Limit::UNLIMITED.to_string(), "unlimited");
478    }
479
480    /// The resource each one is documented after, or `None` for the last.
481    ///
482    /// A chain rather than a list, and that is the whole point: the match has no
483    /// wildcard, so a resource added to the enum stops this compiling until it
484    /// is linked in, and linking it in puts it in the chain the test below holds
485    /// [`Resource::ALL`] to. A test that merely walked `ALL` could never notice
486    /// a variant `ALL` does not hold.
487    fn successor(resource: Resource) -> Option<Resource> {
488        match resource {
489            Resource::AddressSpace => Some(Resource::CoreDump),
490            Resource::CoreDump => Some(Resource::CpuTime),
491            Resource::CpuTime => Some(Resource::Data),
492            Resource::Data => Some(Resource::FileSize),
493            Resource::FileSize => Some(Resource::LockedMemory),
494            Resource::LockedMemory => Some(Resource::OpenFiles),
495            Resource::OpenFiles => Some(Resource::PendingSignals),
496            Resource::PendingSignals => Some(Resource::Processes),
497            Resource::Processes => Some(Resource::Stack),
498            Resource::Stack => None,
499        }
500    }
501
502    /// `ALL` is the roster, and it holds every resource.
503    ///
504    /// The omission this catches would be quiet rather than loud: `ALL` is what
505    /// `fcage` offers under `--help` and searches to parse a `--rlimit`, so a
506    /// variant left out of it is a limit the command line cannot name and the
507    /// help does not list, refused nowhere and reported nowhere.
508    #[test]
509    fn the_roster_holds_every_resource_in_the_documented_order() {
510        let mut chain = vec![Resource::AddressSpace];
511        while let Some(next) = successor(*chain.last().expect("the chain starts somewhere")) {
512            chain.push(next);
513        }
514        assert_eq!(Resource::ALL, chain);
515    }
516
517    /// Every resource's `spelling` is the name the configuration formats
518    /// accept, which is serde's kebab-case rename of the variant. The two are
519    /// written out separately, so this proves they agree, over the roster
520    /// [`the_roster_holds_every_resource_in_the_documented_order`] proves is
521    /// complete.
522    #[cfg(feature = "serde")]
523    #[test]
524    fn every_resources_spelling_is_the_name_a_profile_accepts() {
525        use serde::Deserialize as _;
526        for resource in Resource::ALL {
527            let parsed = Resource::deserialize(serde::de::value::StrDeserializer::<
528                serde::de::value::Error,
529            >::new(resource.spelling()))
530            .unwrap_or_else(|err| panic!("{:?} should parse: {err}", resource.spelling()));
531            assert_eq!(parsed, *resource);
532        }
533    }
534
535    #[test]
536    fn the_kernels_infinity_is_no_limit_rather_than_an_amount() {
537        // `RLIM_INFINITY` is `u64::MAX`, so the two are one value to the
538        // kernel and the type must not offer a distinction it cannot keep.
539        assert_eq!(Limit::of(u64::MAX), Limit::UNLIMITED);
540        assert_eq!(Limit::of(u64::MAX).amount(), None);
541        assert_eq!(Limit::of(u64::MAX - 1).amount(), Some(u64::MAX - 1));
542    }
543}