tiffin/
lib.rs

1use itertools::Itertools;
2use std::{
3    collections::HashMap,
4    fs::File,
5    os::fd::AsRawFd,
6    path::{Component, Path, PathBuf},
7};
8use sys_mount::{FilesystemType, Mount, MountFlags, Unmount, UnmountDrop, UnmountFlags};
9/// Mount object struct
10#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
11pub struct MountTarget {
12    pub target: PathBuf,
13    pub fstype: Option<String>,
14    pub flags: MountFlags,
15    pub data: Option<String>,
16}
17
18impl Default for MountTarget {
19    fn default() -> Self {
20        Self {
21            target: Default::default(),
22            fstype: Default::default(),
23            flags: MountFlags::empty(),
24            data: Default::default(),
25        }
26    }
27}
28
29impl MountTarget {
30    /// Create a new mount object
31    pub fn new(
32        target: PathBuf,
33        fstype: Option<String>,
34        flags: MountFlags,
35        data: Option<String>,
36    ) -> Self {
37        Self {
38            target,
39            fstype,
40            flags,
41            data,
42        }
43    }
44
45    #[tracing::instrument]
46    pub fn mount(&self, source: &PathBuf, root: &Path) -> std::io::Result<UnmountDrop<Mount>> {
47        // sanitize target path
48        let target = self.target.strip_prefix("/").unwrap_or(&self.target);
49        tracing::info!(?root, "Mounting {source:?} to {target:?}");
50        let target = root.join(target);
51        std::fs::create_dir_all(&target)?;
52
53        // nix::mount::mount(
54        //     source,
55        //     &target,
56        //     self.fstype.as_deref(),
57        //     self.flags,
58        //     self.data.as_deref(),
59        // )?;
60        let mut mount = Mount::builder().flags(self.flags);
61        if let Some(fstype) = &self.fstype {
62            mount = mount.fstype(FilesystemType::Manual(fstype));
63        }
64
65        if let Some(data) = &self.data {
66            mount = mount.data(data);
67        }
68
69        let mount = mount.mount_autodrop(source, &target, UnmountFlags::empty())?;
70        Ok(mount)
71    }
72
73    pub fn umount(&self, root: &Path) -> std::io::Result<()> {
74        // sanitize target path
75        let target = self.target.strip_prefix("/").unwrap_or(&self.target);
76        let target = root.join(target);
77
78        nix::mount::umount(&target)?;
79        Ok(())
80    }
81}
82
83/// Mount Table Struct
84/// This is used to mount filesystems inside the container. It is essentially an fstab, for the container.
85#[derive(Default)]
86pub struct MountTable {
87    /// The table of mounts
88    /// The key is the device name, and value is the mount object
89    inner: HashMap<PathBuf, MountTarget>,
90    mounts: Vec<UnmountDrop<Mount>>,
91}
92
93impl MountTable {
94    pub fn new() -> Self {
95        Self {
96            inner: HashMap::new(),
97            mounts: Vec::new(),
98        }
99    }
100    /// Sets the mount table
101    pub fn set_table(&mut self, table: HashMap<PathBuf, MountTarget>) {
102        self.inner = table;
103    }
104
105    /// Adds a mount to the table
106    pub fn add_mount(&mut self, mount: MountTarget, source: PathBuf) {
107        self.inner.insert(source, mount);
108    }
109
110    pub fn add_sysmount(&mut self, mount: UnmountDrop<Mount>) {
111        self.mounts.push(mount);
112    }
113
114    /// Sort mounts by mountpoint and depth
115    /// Closer to root, and root is first
116    /// everything else is either sorted by depth, or alphabetically
117    fn sort_mounts(&self) -> impl Iterator<Item = (&PathBuf, &MountTarget)> {
118        self.inner.iter().sorted_by(|(_, a), (_, b)| {
119            match (a.target.components().count(), b.target.components().count()) {
120                (1, _) if a.target.components().next() == Some(Component::RootDir) => {
121                    std::cmp::Ordering::Less
122                } // root dir
123                (_, 1) if b.target.components().next() == Some(Component::RootDir) => {
124                    std::cmp::Ordering::Greater
125                } // root dir
126                (x, y) if x == y => a.target.cmp(&b.target),
127                (x, y) => x.cmp(&y),
128            }
129        })
130    }
131
132    /// Mounts everything to the root
133    pub fn mount_chroot(&mut self, root: &Path) -> std::io::Result<()> {
134        // let ordered = self.sort_mounts();
135        // for (source, mount) in ordered {
136        //     let m = mount.mount(source, root)?;
137        //     self.mounts.push(m);
138        // }
139        //
140        self.mounts = self
141            .sort_mounts()
142            .map(|(source, mount)| {
143                tracing::trace!(?mount, ?source, "Mounting");
144                mount.mount(source, root)
145            })
146            .collect::<std::io::Result<_>>()?;
147        Ok(())
148    }
149
150    pub fn umount_chroot(&mut self) -> std::io::Result<()> {
151        self.mounts.drain(..).rev().try_for_each(|mount| {
152            tracing::trace!("Unmounting {:?}", mount.target_path());
153            // this causes ENOENT when not chrooting properly
154            mount.unmount(UnmountFlags::DETACH)
155        })
156    }
157}
158
159/// Container Struct
160/// A tiffin container is a simple chroot jail that can be used to run code inside.
161///
162/// May require root permissions to use.
163// #[derive(Debug)]
164pub struct Container {
165    pub root: PathBuf,
166    pub mount_table: MountTable,
167    _initialized: bool,
168    chroot: bool,
169    sysroot: File,
170    pwd: File,
171}
172
173impl Container {
174    /// Enter chroot jail
175    ///
176    /// This makes use of the `chroot` syscall to enter the chroot jail.
177    ///
178    #[inline(always)]
179    pub fn chroot(&mut self) -> std::io::Result<()> {
180        if !self._initialized {
181            // mount the tmpfs first, idiot proofing in case the
182            // programmer forgets to mount it before chrooting
183            //
184            // This should be fine as it's going to be dismounted after dropping regardless
185            self.mount()?;
186        }
187
188        nix::unistd::chroot(&self.root)?;
189        self.chroot = true;
190        nix::unistd::chdir("/")?;
191        Ok(())
192    }
193
194    /// Exits the chroot
195    ///
196    /// This works by changing the current working directory
197    /// to a raw file descriptor of the sysroot we saved earlier
198    /// in `[Container::new]`, and then chrooting to the directory
199    /// we just moved to.
200    ///
201    /// We then also take the pwd stored earlier and move back to it,
202    /// for good measure.
203    #[inline(always)]
204    pub fn exit_chroot(&mut self) -> std::io::Result<()> {
205        nix::unistd::fchdir(self.sysroot.as_raw_fd())?;
206        nix::unistd::chroot(".")?;
207        self.chroot = false;
208
209        // Let's return back to pwd
210        nix::unistd::fchdir(self.pwd.as_raw_fd())?;
211        Ok(())
212    }
213
214    /// Create a new tiffin container
215    ///
216    /// To use it, you need to create a new container with `root`
217    /// set to the location of the chroot you'd like to use.
218    pub fn new(chrootpath: PathBuf) -> Self {
219        let pwd = std::fs::File::open("/proc/self/cwd").unwrap();
220        let sysroot = std::fs::File::open("/").unwrap();
221
222        let mut container = Self {
223            pwd,
224            root: chrootpath,
225            mount_table: MountTable::new(),
226            sysroot,
227            _initialized: false,
228            chroot: false,
229        };
230
231        container.setup_minimal_mounts();
232
233        container
234    }
235
236    /// Run a function inside the container chroot
237    #[inline(always)]
238    pub fn run<F, T>(&mut self, f: F) -> std::io::Result<T>
239    where
240        F: FnOnce() -> T,
241    {
242        // Only mount and chroot if we're not already initialized
243        if !self._initialized {
244            self.mount()?;
245        }
246        if !self.chroot {
247            self.chroot()?;
248        }
249        tracing::trace!("Running function inside container");
250        let ret = f();
251        if self.chroot {
252            self.exit_chroot()?;
253        }
254        if self._initialized {
255            self.umount()?;
256        }
257        Ok(ret)
258    }
259
260    /// Start mounting files inside the container
261    pub fn mount(&mut self) -> std::io::Result<()> {
262        self.mount_table.mount_chroot(&self.root)?;
263        self._initialized = true;
264        Ok(())
265    }
266
267    /// Unmounts all mountpoints inside the container
268    pub fn umount(&mut self) -> std::io::Result<()> {
269        self.mount_table.umount_chroot()?;
270        self._initialized = false;
271        Ok(())
272    }
273
274    /// Adds a bind mount for the system's root filesystem to
275    /// the container's root filesystem at `/run/host`
276    pub fn host_bind_mount(&mut self) -> &mut Self {
277        self.bind_mount(PathBuf::from("/"), PathBuf::from("/run/host"));
278        self
279    }
280
281    /// Adds a bind mount to a file or directory inside the container
282    pub fn bind_mount(&mut self, source: PathBuf, target: PathBuf) {
283        self.mount_table.add_mount(
284            MountTarget {
285                target,
286                flags: MountFlags::BIND,
287                ..MountTarget::default()
288            },
289            source,
290        );
291    }
292
293    /// Adds an additional mount target to the container mount table
294    ///
295    /// Useful for mounting disks or other filesystems
296    pub fn add_mount(&mut self, mount: MountTarget, source: PathBuf) {
297        self.mount_table.add_mount(mount, source);
298    }
299
300    fn setup_minimal_mounts(&mut self) {
301        self.mount_table.add_mount(
302            MountTarget {
303                target: "proc".into(),
304                fstype: Some("proc".to_string()),
305                ..MountTarget::default()
306            },
307            PathBuf::from("/proc"),
308        );
309
310        self.mount_table.add_mount(
311            MountTarget {
312                target: "sys".into(),
313                fstype: Some("sysfs".to_string()),
314                ..MountTarget::default()
315            },
316            PathBuf::from("/sys"),
317        );
318
319        self.bind_mount("/dev".into(), "dev".into());
320        self.bind_mount("/dev/pts".into(), "dev/pts".into());
321    }
322}
323
324impl Drop for Container {
325    fn drop(&mut self) {
326        tracing::trace!("Dropping container, images will be unmounted");
327        if self.chroot {
328            self.exit_chroot().unwrap();
329        }
330        if self._initialized {
331            self.umount().unwrap();
332        }
333    }
334}
335
336// We can't really reproduce this test in a CI environment, so let's just ignore it
337#[cfg(test)]
338// Test only if we're running as root
339mod tests {
340    use super::*;
341    use std::path::PathBuf;
342    #[ignore = "This test requires root"]
343    #[test]
344    fn test_container() {
345        std::fs::create_dir_all("/tmp/tiffin").unwrap();
346        let mut container = Container::new(PathBuf::from("/tmp/tiffin"));
347        container
348            .run(|| std::fs::create_dir_all("/tmp/tiffin/test").unwrap())
349            .unwrap();
350    }
351}