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
13const 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 "SYSTEMROOT",
38 "USERPROFILE",
39 "APPDATA",
40 "LOCALAPPDATA",
41 "PATHEXT",
42 "COMSPEC",
43];
44
45const CARGO_ENV_PREFIX: &str = "CARGO_";
51
52const OUTPUT_TAIL_BYTES: usize = 16 * 1024;
54const OUTPUT_GRACE_PERIOD: Duration = Duration::from_secs(5);
57
58#[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 #[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 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 #[cfg(unix)]
146 command.process_group(0);
147 command
148 }
149}
150
151async 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
177async 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 #[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 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 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}