compose_spec/service/
device.rs

1//! Provides [`Device`] and [`CgroupRule`] for the `devices` and `device_cgroup_rules` fields of
2//! [`Service`](super::Service).
3
4use std::{
5    fmt::{self, Display, Formatter, Write},
6    num::ParseIntError,
7    path::PathBuf,
8    str::FromStr,
9};
10
11use compose_spec_macros::{DeserializeFromStr, SerializeDisplay};
12use thiserror::Error;
13
14use super::{volumes::AbsolutePathError, AbsolutePath};
15
16/// Device mapping from the host to the [`Service`](super::Service) container.
17///
18/// (De)serializes from/to a string in the format `{host_path}:{container_path}[:{permissions}]`
19/// e.g. `/host:/container:rwm`.
20///
21/// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/05-services.md#devices)
22#[derive(SerializeDisplay, DeserializeFromStr, Debug, Clone, PartialEq, Eq, Hash)]
23pub struct Device {
24    /// Path on the host of the device.
25    pub host_path: PathBuf,
26
27    /// Path inside the container to bind mount the device to.
28    pub container_path: AbsolutePath,
29
30    /// Device cgroup permissions.
31    pub permissions: Permissions,
32}
33
34impl FromStr for Device {
35    type Err = ParseDeviceError;
36
37    fn from_str(s: &str) -> Result<Self, Self::Err> {
38        // Format is "{host_path}:{container_path}[:{permissions}]"
39        let mut split = s.splitn(3, ':');
40
41        let host_path = split.next().ok_or(ParseDeviceError::Empty)?.into();
42        let container_path = split
43            .next()
44            .ok_or(ParseDeviceError::ContainerPathMissing)?
45            .parse()?;
46        let permissions = split.next().unwrap_or_default().parse()?;
47
48        Ok(Self {
49            host_path,
50            container_path,
51            permissions,
52        })
53    }
54}
55
56impl TryFrom<&str> for Device {
57    type Error = ParseDeviceError;
58
59    fn try_from(value: &str) -> Result<Self, Self::Error> {
60        value.parse()
61    }
62}
63
64/// Error returned when parsing a [`Device`] from a string.
65#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ParseDeviceError {
67    /// Given device was an empty string.
68    #[error("device cannot be an empty string")]
69    Empty,
70
71    /// Device was missing a container path.
72    #[error("device must have a container path")]
73    ContainerPathMissing,
74
75    /// Device container path was not absolute.
76    #[error("device container path must be absolute")]
77    ContainerPathAbsolute(#[from] AbsolutePathError),
78
79    /// Error parsing [`Permissions`].
80    #[error("error parsing device permissions")]
81    Permissions(#[from] ParsePermissionsError),
82}
83
84impl Display for Device {
85    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
86        let Self {
87            host_path,
88            container_path,
89            permissions,
90        } = self;
91
92        write!(
93            f,
94            "{}:{}",
95            host_path.display(),
96            container_path.as_path().display(),
97        )?;
98
99        if permissions.any() {
100            write!(f, ":{permissions}")?;
101        }
102
103        Ok(())
104    }
105}
106
107/// [`Device`] or [`CgroupRule`] access permissions.
108#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
109pub struct Permissions {
110    /// Device read permissions.
111    pub read: bool,
112
113    /// Device write permissions.
114    pub write: bool,
115
116    /// Device mknod permissions.
117    pub mknod: bool,
118}
119
120impl Permissions {
121    /// Create [`Permissions`] where all fields are `true`.
122    #[must_use]
123    pub const fn all() -> Self {
124        Self {
125            read: true,
126            write: true,
127            mknod: true,
128        }
129    }
130
131    /// Returns `true` if any of the permissions are `true`.
132    #[must_use]
133    pub const fn any(self) -> bool {
134        let Self { read, write, mknod } = self;
135        read || write || mknod
136    }
137}
138
139impl FromStr for Permissions {
140    type Err = ParsePermissionsError;
141
142    fn from_str(s: &str) -> Result<Self, Self::Err> {
143        let mut read = false;
144        let mut write = false;
145        let mut mknod = false;
146
147        for permission in s.chars() {
148            match permission {
149                'r' => read = true,
150                'w' => write = true,
151                'm' => mknod = true,
152                unknown => return Err(ParsePermissionsError(unknown)),
153            }
154        }
155
156        Ok(Self { read, write, mknod })
157    }
158}
159
160impl TryFrom<&str> for Permissions {
161    type Error = ParsePermissionsError;
162
163    fn try_from(value: &str) -> Result<Self, Self::Error> {
164        value.parse()
165    }
166}
167
168/// Error returned when parsing [`Permissions`] from a string.
169#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
170#[error("invalid device permission `{0}`, must be `r` (read), `w` (write), or `m` (mknod)")]
171pub struct ParsePermissionsError(char);
172
173impl Display for Permissions {
174    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
175        let Self { read, write, mknod } = *self;
176
177        if read {
178            f.write_char('r')?;
179        }
180        if write {
181            f.write_char('w')?;
182        }
183        if mknod {
184            f.write_char('m')?;
185        }
186
187        Ok(())
188    }
189}
190
191/// Device cgroup rule for a [`Service`](super::Service) container.
192///
193/// (De)serializes from/to a string in the format the Linux kernel specifies in the
194/// [Device Whitelist Controller](https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v1/devices.html)
195/// documentation, e.g. `a 7:* rwm`.
196///
197/// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/05-services.md#device_cgroup_rules)
198#[derive(SerializeDisplay, DeserializeFromStr, Debug, Clone, Copy, PartialEq, Eq, Hash)]
199pub struct CgroupRule {
200    /// Device type: character, block, or all.
201    pub kind: Kind,
202
203    /// Device major number.
204    pub major: MajorMinorNumber,
205
206    /// Device minor number.
207    pub minor: MajorMinorNumber,
208
209    /// Device permissions.
210    pub permissions: Permissions,
211}
212
213impl FromStr for CgroupRule {
214    type Err = ParseCgroupRuleError;
215
216    fn from_str(s: &str) -> Result<Self, Self::Err> {
217        // The format is "{kind} {major}:{minor} {permissions}".
218
219        let mut split = s.splitn(3, ' ');
220
221        let kind = split.next().ok_or(ParseCgroupRuleError::Empty)?.parse()?;
222
223        let (major, minor) = split
224            .next()
225            .and_then(|s| s.split_once(':'))
226            .ok_or(ParseCgroupRuleError::MajorMinorNumbersMissing)?;
227        let major = major.parse()?;
228        let minor = minor.parse()?;
229
230        let permissions = split.next().unwrap_or_default().parse()?;
231
232        Ok(Self {
233            kind,
234            major,
235            minor,
236            permissions,
237        })
238    }
239}
240
241impl TryFrom<&str> for CgroupRule {
242    type Error = ParseCgroupRuleError;
243
244    fn try_from(value: &str) -> Result<Self, Self::Error> {
245        value.parse()
246    }
247}
248
249/// Error returned when parsing a [`CgroupRule`] from a string.
250#[derive(Error, Debug, Clone, PartialEq, Eq)]
251pub enum ParseCgroupRuleError {
252    /// Device cgroup rule was empty.
253    #[error("device cgroup rule cannot be empty")]
254    Empty,
255
256    /// Error parsing [`Kind`].
257    #[error("invalid device kind")]
258    Kind(#[from] ParseKindError),
259
260    /// Device major and minor numbers were missing or not in the expected format.
261    #[error("device cgroup rule missing major minor numbers")]
262    MajorMinorNumbersMissing,
263
264    /// Error parsing [`MajorMinorNumber`].
265    #[error("error parsing device major minor number")]
266    MajorMinorNumber(#[from] ParseIntError),
267
268    /// Error parsing [`Permissions`].
269    #[error("error parsing device cgroup rule permissions")]
270    Permissions(#[from] ParsePermissionsError),
271}
272
273impl Display for CgroupRule {
274    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
275        let Self {
276            kind,
277            major,
278            minor,
279            permissions,
280        } = self;
281
282        // The format is "{kind} {major}:{minor} {permissions}".
283
284        write!(f, "{kind} {major}:{minor}")?;
285
286        if permissions.any() {
287            write!(f, " {permissions}")?;
288        }
289
290        Ok(())
291    }
292}
293
294/// Device types for [`CgroupRule`].
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
296pub enum Kind {
297    /// All device types.
298    All,
299
300    /// Character device.
301    Char,
302
303    /// Block device.
304    Block,
305}
306
307impl Kind {
308    /// The character the device type corresponds to.
309    #[must_use]
310    pub const fn as_char(self) -> char {
311        match self {
312            Self::All => 'a',
313            Self::Char => 'c',
314            Self::Block => 'b',
315        }
316    }
317}
318
319impl TryFrom<char> for Kind {
320    type Error = ParseKindError;
321
322    fn try_from(value: char) -> Result<Self, Self::Error> {
323        match value {
324            'a' => Ok(Self::All),
325            'c' => Ok(Self::Char),
326            'b' => Ok(Self::Block),
327            unknown => Err(ParseKindError(unknown.into())),
328        }
329    }
330}
331
332impl FromStr for Kind {
333    type Err = ParseKindError;
334
335    fn from_str(s: &str) -> Result<Self, Self::Err> {
336        match s {
337            "a" => Ok(Self::All),
338            "c" => Ok(Self::Char),
339            "b" => Ok(Self::Block),
340            unknown => Err(ParseKindError(unknown.to_owned())),
341        }
342    }
343}
344
345impl TryFrom<&str> for Kind {
346    type Error = ParseKindError;
347
348    fn try_from(value: &str) -> Result<Self, Self::Error> {
349        value.parse()
350    }
351}
352
353/// Error returned when attempting to parse [`Kind`].
354#[derive(Error, Debug, Clone, PartialEq, Eq)]
355#[error("invalid device kind `{0}`, must be `a` (all), `c` (char), or `b` (block)")]
356pub struct ParseKindError(String);
357
358impl Display for Kind {
359    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
360        f.write_char(self.as_char())
361    }
362}
363
364impl From<Kind> for char {
365    fn from(value: Kind) -> Self {
366        value.as_char()
367    }
368}
369
370/// Device major or minor number for [`CgroupRule`].
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
372pub enum MajorMinorNumber {
373    /// All major/minor numbers (*).
374    All,
375
376    /// A specific major/minor number.
377    Integer(u16),
378}
379
380impl PartialEq<u16> for MajorMinorNumber {
381    fn eq(&self, other: &u16) -> bool {
382        match self {
383            Self::All => false,
384            Self::Integer(num) => num.eq(other),
385        }
386    }
387}
388
389impl From<u16> for MajorMinorNumber {
390    fn from(value: u16) -> Self {
391        Self::Integer(value)
392    }
393}
394
395impl FromStr for MajorMinorNumber {
396    type Err = ParseIntError;
397
398    fn from_str(s: &str) -> Result<Self, Self::Err> {
399        if s.is_empty() || s == "*" {
400            Ok(Self::All)
401        } else {
402            s.parse().map(Self::Integer)
403        }
404    }
405}
406
407impl TryFrom<&str> for MajorMinorNumber {
408    type Error = ParseIntError;
409
410    fn try_from(value: &str) -> Result<Self, Self::Error> {
411        value.parse()
412    }
413}
414
415impl Display for MajorMinorNumber {
416    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
417        match self {
418            Self::All => f.write_char('*'),
419            Self::Integer(num) => Display::fmt(num, f),
420        }
421    }
422}
423
424#[cfg(test)]
425#[allow(clippy::unwrap_used)]
426mod tests {
427    use proptest::{
428        arbitrary::any,
429        prop_assert_eq, prop_compose, prop_oneof, proptest,
430        strategy::{Just, Strategy},
431    };
432
433    use super::*;
434
435    mod device {
436        use crate::service::tests::path_no_colon;
437
438        use super::*;
439
440        #[test]
441        fn from_str() {
442            let device = Device {
443                host_path: "/host".into(),
444                container_path: "/container".parse().unwrap(),
445                permissions: Permissions {
446                    read: true,
447                    write: true,
448                    mknod: false,
449                },
450            };
451            assert_eq!(device, "/host:/container:rw".parse().unwrap());
452        }
453
454        #[test]
455        fn display() {
456            let device = Device {
457                host_path: "/host".into(),
458                container_path: "/container".parse().unwrap(),
459                permissions: Permissions {
460                    read: true,
461                    write: true,
462                    mknod: false,
463                },
464            };
465            assert_eq!(device.to_string(), "/host:/container:rw");
466        }
467
468        proptest! {
469            #[test]
470            fn parse_no_panic(string: String) {
471                let _ = string.parse::<Device>();
472            }
473
474            #[test]
475            fn to_string_no_panic(device in device()) {
476                device.to_string();
477            }
478
479            #[test]
480            fn round_trip(device in device()) {
481                prop_assert_eq!(&device, &device.to_string().parse()?);
482            }
483        }
484
485        prop_compose! {
486            fn device()(
487                host_path in path_no_colon(),
488                container_path: AbsolutePath,
489                permissions in permissions()
490            ) -> Device {
491                Device { host_path, container_path, permissions }
492            }
493        }
494    }
495
496    mod permissions {
497        use super::*;
498
499        #[test]
500        fn from_str() {
501            assert_eq!(Permissions::default(), "".parse().unwrap());
502            assert_eq!(
503                Permissions {
504                    read: true,
505                    write: true,
506                    mknod: true
507                },
508                "rwm".parse().unwrap(),
509            );
510        }
511
512        #[test]
513        fn display() {
514            assert!(Permissions::default().to_string().is_empty());
515            assert_eq!(
516                Permissions {
517                    read: true,
518                    write: true,
519                    mknod: true
520                }
521                .to_string(),
522                "rwm",
523            );
524        }
525
526        proptest! {
527            #[test]
528            fn parse_no_panic(string: String) {
529                let _ = string.parse::<Permissions>();
530            }
531
532            #[test]
533            fn to_string_no_panic(permissions in permissions()) {
534                permissions.to_string();
535            }
536
537            #[test]
538            fn round_trip(permissions in permissions()) {
539                prop_assert_eq!(permissions, permissions.to_string().parse()?);
540            }
541        }
542    }
543
544    mod cgroup_rule {
545        use super::*;
546
547        #[test]
548        fn from_str() {
549            let rule = CgroupRule {
550                kind: Kind::Char,
551                major: MajorMinorNumber::Integer(1),
552                minor: MajorMinorNumber::Integer(3),
553                permissions: Permissions {
554                    read: true,
555                    write: false,
556                    mknod: true,
557                },
558            };
559            assert_eq!(rule, "c 1:3 mr".parse().unwrap());
560
561            let rule = CgroupRule {
562                kind: Kind::All,
563                major: MajorMinorNumber::Integer(7),
564                minor: MajorMinorNumber::All,
565                permissions: Permissions::all(),
566            };
567            assert_eq!(rule, "a 7:* rmw".parse().unwrap());
568        }
569
570        #[test]
571        fn display() {
572            let rule = CgroupRule {
573                kind: Kind::Char,
574                major: MajorMinorNumber::Integer(1),
575                minor: MajorMinorNumber::Integer(3),
576                permissions: Permissions {
577                    read: true,
578                    write: false,
579                    mknod: true,
580                },
581            };
582            assert_eq!(rule.to_string(), "c 1:3 rm");
583
584            let rule = CgroupRule {
585                kind: Kind::All,
586                major: MajorMinorNumber::Integer(7),
587                minor: MajorMinorNumber::All,
588                permissions: Permissions::all(),
589            };
590            assert_eq!(rule.to_string(), "a 7:* rwm");
591        }
592
593        proptest! {
594            #[test]
595            fn parse_no_panic(string: String) {
596                let _ = string.parse::<CgroupRule>();
597            }
598
599            #[test]
600            fn to_string_no_panic(rule in cgroup_rule()) {
601                rule.to_string();
602            }
603
604            #[test]
605            fn round_trip(rule in cgroup_rule()) {
606                prop_assert_eq!(rule, rule.to_string().parse()?);
607            }
608        }
609
610        prop_compose! {
611            fn cgroup_rule()(
612                kind in kind(),
613                major in major_minor_number(),
614                minor in major_minor_number(),
615                permissions in permissions(),
616            ) -> CgroupRule {
617                CgroupRule {
618                    kind,
619                    major,
620                    minor,
621                    permissions,
622                }
623            }
624        }
625
626        fn kind() -> impl Strategy<Value = Kind> {
627            prop_oneof![Just(Kind::All), Just(Kind::Char), Just(Kind::Block)]
628        }
629
630        fn major_minor_number() -> impl Strategy<Value = MajorMinorNumber> {
631            prop_oneof![
632                1 => Just(MajorMinorNumber::All),
633                u16::MAX.into() => any::<u16>().prop_map_into(),
634            ]
635        }
636    }
637
638    prop_compose! {
639        fn permissions()(read: bool, write: bool, mknod: bool) -> Permissions {
640            Permissions { read, write, mknod }
641        }
642    }
643}