snix_build/bwrap/
mod.rs

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