Skip to main content

snix_build/bwrap/
mod.rs

1use std::{
2    ffi::OsString,
3    fs,
4    path::{Path, PathBuf},
5    process::Stdio,
6};
7
8use futures::stream::BoxStream;
9use tokio::process::Command;
10
11use crate::sandbox::{
12    InputsProvider, SandboxOutputs, SandboxSpec,
13    event::{SandboxEvent, stream_process},
14};
15
16const COMMON_BWRAP_ARGS: &[&str] = &[
17    "--unshare-uts",
18    "--unshare-ipc",
19    "--unshare-pid",
20    "--die-with-parent",
21    "--as-pid-1",
22    "--unshare-user",
23    "--uid",
24    "1000",
25    "--gid",
26    "100",
27    "--clearenv",
28    "--tmpfs",
29    "/",
30    "--dev",
31    "/dev",
32    "--proc",
33    "/proc",
34    "--tmpfs",
35    "/tmp",
36];
37
38const ETC_PASSWD: &[u8] = b"
39root:x:0:0:Nix build user:/build:/noshell
40nixbld:x:1000:100:Nix build user:/build:/noshell
41nobody:x:65534:65534:Nobody:/:/noshell
42";
43
44const ETC_GROUP: &[u8] = b"
45root:x:0:
46nixbld:!:100:
47nogroup:x:65534:
48";
49
50const ETC_HOSTS: &[u8] = b"
51127.0.0.1 localhost
52::1 localhost
53";
54
55const ETC_NSSWITCH: &[u8] = b"
56hosts: files dns
57services: files
58";
59
60/// Bubblewrap based sandbox executor.
61///
62/// It executes the sandbox command in separate uts, ipc, pid and user namespaces,
63/// always runs as uid=1000(nixbld) and gid=100(nixbld) inside the namespace. Provides sane
64/// defaults for various `/etc` files.
65///
66/// Network is optionally disabled with a separate network namespace based on the value of
67/// [SandboxSpec::allow_network].
68///
69/// The root filesystem is tmpfs, has /dev and /proc.
70///
71/// The rest of the filesystem is based on the [SandboxSpec::scratches], [SandboxSpec::additional_files]
72/// and [SandboxSpec::inputs_provider].
73///
74/// # Scratches
75///
76/// A list of read-write directories available inside the sandbox, these directories are also left
77/// available on the host after the sandbox has finished.
78///
79/// # Additional files
80///
81/// A list of read-write files whose path currently *must* resolve into one of the Scratches.
82///
83/// # Build Inputs([SandboxSpec::inputs_provider])
84///
85/// A read-only directory that contains any files required by the sandboxed command, e.g
86/// `/nix/store`.
87/// Before the sandbox starts, the [SandboxSpec::inputs_provider] will have a chance to populate
88/// this directory and clean up after the sandbox is stopped.
89///
90/// **Note**: If the build inputs directory overlaps with any of the scratches, an overlayfs mount
91/// will be created for that scratch so it remains writable, i.e. the sandboxed command can create
92/// new files/directories.
93pub struct Bwrap {
94    host_workdir: PathBuf,
95    args: Vec<OsString>,
96    inputs_provider: InputsProvider,
97}
98
99/// Allows finding outputs produced by the sandboxed command.
100#[derive(Debug, Clone)]
101pub struct BwrapOutputs {
102    scratch_dir: PathBuf,
103}
104
105impl SandboxOutputs for BwrapOutputs {
106    /// Allows finding outputs produced by the sandboxed command.
107    ///
108    /// The command must write into one of the scratches.
109    async fn find_path(&self, path: impl AsRef<Path>) -> Option<PathBuf> {
110        let path = self.scratch_dir.join(path);
111        // Exists follows symlinks so may return false incorrectly, as nix builds are apparently
112        // allowed to produce broken symlinks as their $out...
113        // i.e. `runCommand "test" {} "ln -s IdontExist $out"` is a valid nix build.
114        //
115        // Additionally, by the time find_path is called, the build has already unmonted the
116        // fuse store, which means that even valid symlinks can be "broken" during ingestion.
117        if let Ok(metadata) = tokio::fs::symlink_metadata(&path).await {
118            metadata.is_symlink() || metadata.is_dir() || metadata.is_file()
119        } else {
120            false
121        }
122        .then_some(path)
123    }
124}
125
126impl Bwrap {
127    /// Run the sandbox and return a stream of events alongside an outputs handle.
128    pub fn run(self) -> std::io::Result<(BoxStream<'static, SandboxEvent>, BwrapOutputs)> {
129        let outputs = BwrapOutputs {
130            scratch_dir: self.host_workdir.join("scratches"),
131        };
132
133        let guard = self
134            .inputs_provider
135            .provide_inputs(self.host_workdir.join("host_inputs_dir"))?;
136
137        let child = Command::new("bwrap")
138            .args(self.args)
139            // Make sure we've closed stdin otherwise builds can hang forever blocked on std io.
140            .stdin(Stdio::null())
141            .stdout(Stdio::piped())
142            .stderr(Stdio::piped())
143            .kill_on_drop(true)
144            .spawn()?;
145
146        let stream = stream_process(child, guard)?;
147
148        Ok((stream, outputs))
149    }
150
151    /// Constructor.
152    pub fn initialize(spec: SandboxSpec) -> std::io::Result<Bwrap> {
153        let scratch_dir = spec.host_workdir().join("scratches");
154        fs::create_dir_all(&scratch_dir)?;
155        let mut args: Vec<OsString> = COMMON_BWRAP_ARGS.iter().map(|s| s.into()).collect();
156        if !spec.allow_network() {
157            args.push("--unshare-net".into());
158        }
159        for env in spec.env_vars() {
160            args.extend([
161                "--setenv".into(),
162                env.key.clone().into(),
163                str::from_utf8(&env.value)
164                    .expect("invalid string in env")
165                    .into(),
166            ]);
167        }
168
169        let host_inputs_dir = spec.host_workdir().join("host_inputs_dir");
170        fs::create_dir_all(&host_inputs_dir)?;
171        args.extend([
172            "--ro-bind".into(),
173            Path::new("/").join(&host_inputs_dir).into(),
174            Path::new("/")
175                .join(spec.inputs_provider().inputs_dir())
176                .into(),
177        ]);
178        for scratch in spec.scratches() {
179            let scratch_path = scratch_dir.join(scratch);
180            fs::create_dir_all(&scratch_path)?;
181            if scratch == spec.inputs_provider().inputs_dir() {
182                let overlay_workdir = spec.host_workdir().join("overlay_workdir");
183                fs::create_dir_all(&overlay_workdir)?;
184                args.extend([
185                    "--overlay-src".into(),
186                    OsString::from(&host_inputs_dir),
187                    "--overlay".into(),
188                    scratch_path.into(),
189                    overlay_workdir.into(),
190                    Path::new("/")
191                        .join(spec.inputs_provider().inputs_dir())
192                        .into(),
193                ]);
194            } else {
195                args.extend([
196                    "--bind".into(),
197                    scratch_path.into(),
198                    Path::new("/").join(scratch).into(),
199                ]);
200            }
201        }
202        args.extend([
203            "--chdir".into(),
204            Path::new("/").join(spec.sandbox_workdir()).into(),
205        ]);
206
207        if let Some(shell) = spec.provide_shell() {
208            args.extend_from_slice(&["--ro-bind".into(), shell.into(), "/bin/sh".into()]);
209        }
210
211        for file in spec.additional_files() {
212            let mut found = false;
213            for scratch in spec.scratches() {
214                if file.path.starts_with(scratch) {
215                    found = true;
216                }
217            }
218            if !found {
219                return Err(std::io::Error::other(format!(
220                    "Additional file does not belong to any scratch: {:?}",
221                    file.path
222                )));
223            }
224            // TODO: prevent files from escaping the sandbox, i.e. don't allow additional files
225            // of this form: build/../../hello.
226            let file_path = scratch_dir.join(&file.path);
227            fs::create_dir_all(file_path.parent().expect("parent"))?;
228            fs::write(&file_path, &file.contents)?;
229        }
230        let etc = &spec.host_workdir().join("etc");
231        fs::create_dir_all(etc)?;
232        fs::write(etc.join("passwd"), ETC_PASSWD)?;
233        fs::write(etc.join("group"), ETC_GROUP)?;
234        fs::write(etc.join("hosts"), ETC_HOSTS)?;
235        fs::write(etc.join("nsswitch.conf"), ETC_NSSWITCH)?;
236
237        args.extend([
238            "--ro-bind".into(),
239            etc.join("passwd").into(),
240            "/etc/passwd".into(),
241            "--ro-bind".into(),
242            etc.join("group").into(),
243            "/etc/group".into(),
244        ]);
245        if spec.allow_network() {
246            args.extend([
247                "--ro-bind".into(),
248                "/etc/hosts".into(),
249                "/etc/hosts".into(),
250                "--ro-bind".into(),
251                "/etc/resolv.conf".into(),
252                "/etc/resolv.conf".into(),
253                "--ro-bind".into(),
254                "/etc/services".into(),
255                "/etc/services".into(),
256                "--ro-bind".into(),
257                etc.join("nsswitch.conf").into(),
258                "/etc/nsswitch.conf".into(),
259            ]);
260            //TODO: Create /etc/nsswitch.conf with: "hosts: files dns\nservices: files\n"
261        } else {
262            // Use predefined /etc/hosts like nix does.
263            // Among other things it is required for libuv getaddrinfo() tests to pass.
264            args.extend([
265                "--ro-bind".into(),
266                etc.join("hosts").into(),
267                "/etc/hosts".into(),
268            ]);
269        }
270        args.extend(spec.command().into_iter().map(|s| s.into()));
271
272        Ok(Self {
273            host_workdir: spec.host_workdir().into(),
274            args,
275            inputs_provider: spec.into(),
276        })
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use tempfile::TempDir;
284
285    #[tokio::test]
286    async fn test_find_path() {
287        let temp_dir = TempDir::new().expect("failed to create temp directory");
288        let scratch_dir = temp_dir.path();
289
290        let file_path = scratch_dir.join("file.txt");
291        std::fs::write(&file_path, b"hello").expect("failed to write test file");
292
293        let symlink_path = scratch_dir.join("link");
294        std::os::unix::fs::symlink("nonexistent", &symlink_path)
295            .expect("failed to create test symlink");
296
297        let outputs = BwrapOutputs {
298            scratch_dir: scratch_dir.to_path_buf(),
299        };
300
301        assert_eq!(outputs.find_path("file.txt").await, Some(file_path));
302        assert_eq!(outputs.find_path("link").await, Some(symlink_path));
303        assert_eq!(outputs.find_path("missing").await, None);
304    }
305}