Skip to main content

kellnr_docs/
build_process.rs

1use std::ffi::OsString;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4use std::time::Duration;
5
6use kellnr_settings::cli::{DOC_BUILD_CRATESIO_INDEX_ARG, DOC_BUILD_SUBCOMMAND};
7use tokio::io::{AsyncRead, AsyncReadExt};
8use tokio::process::{Child, Command};
9use tracing::warn;
10
11use crate::docs_error::DocsError;
12
13/// Environment variables passed on to the docs build process. Everything else,
14/// especially the `KELLNR_*` settings with their secrets, is removed.
15const ALLOWED_ENV_VARS: &[&str] = &[
16    "PATH",
17    "HOME",
18    "USER",
19    "LANG",
20    "LC_ALL",
21    "TMPDIR",
22    "TEMP",
23    "TMP",
24    "RUSTUP_HOME",
25    "RUSTUP_TOOLCHAIN",
26    "HTTP_PROXY",
27    "HTTPS_PROXY",
28    "ALL_PROXY",
29    "NO_PROXY",
30    "http_proxy",
31    "https_proxy",
32    "all_proxy",
33    "no_proxy",
34    "SSL_CERT_FILE",
35    "SSL_CERT_DIR",
36    // Required to start processes on Windows.
37    "SYSTEMROOT",
38    "USERPROFILE",
39    "APPDATA",
40    "LOCALAPPDATA",
41    "PATHEXT",
42    "COMSPEC",
43];
44
45/// Prefix of cargo's own configuration variables, e.g. `CARGO_HOME`,
46/// `CARGO_HTTP_CAINFO` or `CARGO_REGISTRIES_<NAME>_TOKEN`, which deployments
47/// use to let the docs build authenticate against Kellnr's index. These are
48/// visible to the crate's build scripts, so such a token should belong to a
49/// dedicated read-only user.
50const CARGO_ENV_PREFIX: &str = "CARGO_";
51
52/// Keep at most this many bytes of the build output for error messages.
53const OUTPUT_TAIL_BYTES: usize = 16 * 1024;
54/// How long to wait for the build output after the build process exited.
55/// Processes spawned by the build may keep the output pipe open.
56const OUTPUT_GRACE_PERIOD: Duration = Duration::from_secs(5);
57
58/// Runs docs builds in a separate process with a minimal environment.
59///
60/// The build compiles untrusted crates, which run build scripts and proc
61/// macros. The child process re-executes the Kellnr binary with the hidden
62/// [`DOC_BUILD_SUBCOMMAND`], optionally prefixed by an operator supplied
63/// wrapper (e.g. bubblewrap) for further confinement.
64#[derive(Debug, Clone)]
65pub struct DocBuilder {
66    program: PathBuf,
67    wrapper: Vec<String>,
68    cratesio_index: Option<String>,
69    timeout: Option<Duration>,
70}
71
72impl DocBuilder {
73    pub fn new(program: PathBuf, wrapper: Vec<String>, cratesio_index: Option<String>) -> Self {
74        Self {
75            program,
76            wrapper,
77            cratesio_index,
78            timeout: None,
79        }
80    }
81
82    /// Kill builds that run longer than `timeout`, including all processes
83    /// they started. `Duration::ZERO` disables the timeout.
84    #[must_use]
85    pub fn with_timeout(mut self, timeout: Duration) -> Self {
86        self.timeout = (!timeout.is_zero()).then_some(timeout);
87        self
88    }
89
90    /// Build the docs of the crate at `crate_path` into `crate_path/target/doc`.
91    pub async fn build(&self, crate_path: &Path) -> Result<(), DocsError> {
92        let mut child = self.command(crate_path).spawn().map_err(|e| {
93            DocsError::CargoError(format!("failed to start docs build process: {e}"))
94        })?;
95        let output = tokio::spawn(read_tail(
96            child.stderr.take().expect("stderr is piped"),
97            OUTPUT_TAIL_BYTES,
98        ));
99
100        let status = match self.timeout {
101            None => child.wait().await?,
102            Some(timeout) => {
103                let Ok(status) = tokio::time::timeout(timeout, child.wait()).await else {
104                    kill_build(&mut child).await;
105                    output.abort();
106                    return Err(DocsError::BuildTimeout(timeout.as_secs()));
107                };
108                status?
109            }
110        };
111        let output = match tokio::time::timeout(OUTPUT_GRACE_PERIOD, output).await {
112            Ok(output) => output.unwrap_or_default(),
113            Err(_) => String::from("<build output not available>"),
114        };
115        if status.success() {
116            Ok(())
117        } else {
118            Err(DocsError::CargoError(format!(
119                "docs build failed ({status}): {output}"
120            )))
121        }
122    }
123
124    fn command(&self, crate_path: &Path) -> Command {
125        let mut args: Vec<OsString> = self.wrapper.iter().map(OsString::from).collect();
126        args.push(self.program.clone().into_os_string());
127        args.push(DOC_BUILD_SUBCOMMAND.into());
128        args.push(crate_path.into());
129        if let Some(index) = &self.cratesio_index {
130            args.push(format!("--{DOC_BUILD_CRATESIO_INDEX_ARG}").into());
131            args.push(index.into());
132        }
133
134        let mut command = Command::new(&args[0]);
135        command
136            .args(&args[1..])
137            .env_clear()
138            .envs(allowed_env(std::env::vars_os()))
139            .stdin(Stdio::null())
140            .stdout(Stdio::null())
141            .stderr(Stdio::piped())
142            .kill_on_drop(true);
143        // Run the build in its own process group, so a timeout kills the
144        // compiler and build scripts started by cargo as well.
145        #[cfg(unix)]
146        command.process_group(0);
147        command
148    }
149}
150
151/// Kill the build process and, on Unix, its whole process group.
152async fn kill_build(child: &mut Child) {
153    #[cfg(unix)]
154    if let Some(pid) = child
155        .id()
156        .and_then(|id| i32::try_from(id).ok())
157        .and_then(rustix::process::Pid::from_raw)
158        && let Err(e) = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL)
159    {
160        warn!("Failed to kill docs build process group: {e}");
161    }
162    if let Err(e) = child.kill().await {
163        warn!("Failed to kill docs build process: {e}");
164    }
165}
166
167fn allowed_env(
168    vars: impl Iterator<Item = (OsString, OsString)>,
169) -> impl Iterator<Item = (OsString, OsString)> {
170    vars.filter(|(key, _)| key.to_str().is_some_and(is_allowed_env_var))
171}
172
173fn is_allowed_env_var(key: &str) -> bool {
174    ALLOWED_ENV_VARS.contains(&key) || key.starts_with(CARGO_ENV_PREFIX)
175}
176
177/// Read `reader` to the end and return the last `max_bytes` bytes as text.
178async fn read_tail(mut reader: impl AsyncRead + Unpin, max_bytes: usize) -> String {
179    let mut tail = Vec::new();
180    let mut buf = [0u8; 8192];
181    while let Ok(n) = reader.read(&mut buf).await {
182        if n == 0 {
183            break;
184        }
185        tail.extend_from_slice(&buf[..n]);
186        if tail.len() > max_bytes {
187            tail.drain(..tail.len() - max_bytes);
188        }
189    }
190    String::from_utf8_lossy(&tail).into_owned()
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn vars(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
198        pairs
199            .iter()
200            .map(|(k, v)| (OsString::from(k), OsString::from(v)))
201            .collect()
202    }
203
204    #[test]
205    fn allowed_env_drops_kellnr_settings_and_unknown_vars() {
206        let env = vars(&[
207            ("PATH", "/usr/bin"),
208            ("CARGO_HOME", "/cargo"),
209            ("RUSTUP_TOOLCHAIN", "stable"),
210            ("CARGO_REGISTRIES_KELLNR_TOKEN", "doc-token"),
211            ("KELLNR_POSTGRESQL__PWD", "secret"),
212            ("KELLNR_SETUP__ADMIN_TOKEN", "token"),
213            ("AWS_SECRET_ACCESS_KEY", "aws"),
214        ]);
215
216        let allowed: Vec<_> = allowed_env(env.into_iter()).collect();
217
218        assert_eq!(
219            allowed,
220            vars(&[
221                ("PATH", "/usr/bin"),
222                ("CARGO_HOME", "/cargo"),
223                ("RUSTUP_TOOLCHAIN", "stable"),
224                ("CARGO_REGISTRIES_KELLNR_TOKEN", "doc-token"),
225            ])
226        );
227    }
228
229    #[tokio::test]
230    async fn read_tail_keeps_only_the_end_of_the_output() {
231        let output = read_tail(&b"0123456789"[..], 4).await;
232
233        assert_eq!(output, "6789");
234    }
235
236    /// Shell wrapper that records its environment and arguments to `out`
237    /// instead of running the build.
238    #[cfg(unix)]
239    fn recording_wrapper(out: &Path) -> Vec<String> {
240        vec![
241            "/bin/sh".to_string(),
242            "-c".to_string(),
243            "env > \"$0\"; echo \"args: $*\" >> \"$0\"".to_string(),
244            out.display().to_string(),
245        ]
246    }
247
248    #[cfg(unix)]
249    #[tokio::test]
250    async fn build_runs_wrapper_with_kellnr_binary_and_cleared_env() {
251        let dir = tempfile::tempdir().unwrap();
252        let out = dir.path().join("out");
253        let builder = DocBuilder::new(
254            PathBuf::from("/opt/kellnr/kellnr"),
255            recording_wrapper(&out),
256            Some("https://proxy/index/".to_string()),
257        );
258
259        builder.build(Path::new("/tmp/my-crate")).await.unwrap();
260
261        let recorded = std::fs::read_to_string(&out).unwrap();
262        assert!(recorded.contains(
263            "args: /opt/kellnr/kellnr doc-build /tmp/my-crate --cratesio-index https://proxy/index/"
264        ));
265        for line in recorded.lines().filter(|l| !l.starts_with("args: ")) {
266            let key = line.split('=').next().unwrap();
267            // The shell itself sets a few variables like PWD or SHLVL.
268            assert!(
269                is_allowed_env_var(key) || ["PWD", "SHLVL", "_", "OLDPWD"].contains(&key),
270                "unexpected variable {key} passed to the docs build"
271            );
272        }
273    }
274
275    #[cfg(unix)]
276    #[tokio::test]
277    async fn build_fails_with_output_of_failed_process() {
278        let builder = DocBuilder::new(
279            PathBuf::from("unused"),
280            vec![
281                "/bin/sh".to_string(),
282                "-c".to_string(),
283                "echo 'error: could not compile' >&2; exit 101".to_string(),
284            ],
285            None,
286        );
287
288        let err = builder.build(Path::new("/tmp/my-crate")).await.unwrap_err();
289
290        assert!(err.to_string().contains("error: could not compile"));
291    }
292
293    #[cfg(unix)]
294    fn process_is_alive(pid: &str) -> bool {
295        std::process::Command::new("kill")
296            .args(["-0", pid])
297            .status()
298            .unwrap()
299            .success()
300    }
301
302    #[cfg(unix)]
303    #[tokio::test]
304    async fn build_timeout_kills_build_and_its_child_processes() {
305        let dir = tempfile::tempdir().unwrap();
306        let pid_file = dir.path().join("pid");
307        let builder = DocBuilder::new(
308            PathBuf::from("unused"),
309            vec![
310                "/bin/sh".to_string(),
311                "-c".to_string(),
312                "sleep 30 & echo $! > \"$0\"; wait".to_string(),
313                pid_file.display().to_string(),
314            ],
315            None,
316        )
317        .with_timeout(Duration::from_millis(500));
318
319        let started = std::time::Instant::now();
320        let err = builder.build(Path::new("/tmp/my-crate")).await.unwrap_err();
321
322        assert!(matches!(err, DocsError::BuildTimeout(_)));
323        assert!(started.elapsed() < Duration::from_secs(10));
324        let pid = std::fs::read_to_string(&pid_file).unwrap();
325        // The killed process may need a moment to be reaped.
326        let deadline = std::time::Instant::now() + Duration::from_secs(5);
327        while process_is_alive(pid.trim()) && std::time::Instant::now() < deadline {
328            tokio::time::sleep(Duration::from_millis(50)).await;
329        }
330        assert!(!process_is_alive(pid.trim()));
331    }
332
333    #[test]
334    fn zero_timeout_disables_the_timeout() {
335        let builder =
336            DocBuilder::new(PathBuf::from("unused"), vec![], None).with_timeout(Duration::ZERO);
337
338        assert_eq!(builder.timeout, None);
339    }
340
341    #[tokio::test]
342    async fn build_fails_when_program_does_not_exist() {
343        let builder = DocBuilder::new(PathBuf::from("/does/not/exist/kellnr"), vec![], None);
344
345        let err = builder.build(Path::new("/tmp/my-crate")).await.unwrap_err();
346
347        assert!(
348            err.to_string()
349                .contains("failed to start docs build process")
350        );
351    }
352}