1use crate::errors::*;
2use serde::{Deserialize, Serialize};
3use std::ffi::OsStr;
4use std::fmt;
5use std::future::{self, Future};
6use std::io::Read;
7use std::process::Stdio;
8use std::str::FromStr;
9use tokio::io::AsyncWriteExt;
10use tokio::process::Command;
11use tokio::signal;
12
13const PODMAN_BINARY: &str = if let Some(path) = option_env!("REPRO_ENV_PODMAN_BINARY") {
16 path
17} else {
18 "podman"
19};
20
21#[derive(Debug, PartialEq, Clone)]
22pub struct ImageRef {
23 pub repo: String,
24 pub tag: Option<String>,
25 pub digest: Option<String>,
26}
27
28impl FromStr for ImageRef {
29 type Err = Error;
30
31 fn from_str(s: &str) -> Result<Self> {
32 if let Some((repo, digest)) = s.split_once('@') {
33 Ok(ImageRef {
34 repo: repo.to_string(),
35 tag: None,
36 digest: Some(digest.to_string()),
37 })
38 } else if let Some((repo, tag)) = s.split_once(':') {
39 Ok(ImageRef {
40 repo: repo.to_string(),
41 tag: Some(tag.to_string()),
42 digest: None,
43 })
44 } else {
45 Ok(ImageRef {
46 repo: s.to_string(),
47 tag: None,
48 digest: None,
49 })
50 }
51 }
52}
53
54impl fmt::Display for ImageRef {
55 fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
56 let repo = &self.repo;
57 if let Some(digest) = &self.digest {
58 write!(w, "{repo}@{digest}")
59 } else if let Some(tag) = &self.tag {
60 write!(w, "{repo}:{tag}")
61 } else {
62 write!(w, "{repo}")
63 }
64 }
65}
66
67#[derive(Debug, Default)]
68pub struct ExecConfig {
69 pub capture_stdout: bool,
70 pub silence_stderr: bool,
71 pub stdin: Option<Vec<u8>>,
72}
73
74pub async fn podman<I, S>(args: I, config: &ExecConfig) -> Result<Vec<u8>>
75where
76 I: IntoIterator<Item = S>,
77 S: AsRef<OsStr> + fmt::Debug,
78{
79 let mut cmd = Command::new(PODMAN_BINARY);
80 let args = args.into_iter().collect::<Vec<_>>();
81 cmd.args(&args);
82 if config.stdin.is_some() {
83 cmd.stdin(Stdio::piped());
84 }
85 if config.capture_stdout {
86 cmd.stdout(Stdio::piped());
87 }
88 if config.silence_stderr {
89 cmd.stderr(Stdio::null());
90 }
91 debug!("Spawning child process: podman {:?}", args);
92 let mut child = cmd.spawn().context("Failed to execute podman binary")?;
93
94 if let Some(buf) = &config.stdin {
96 if let Some(mut stdin) = child.stdin.take() {
97 stdin.write_all(buf).await?;
98 }
99 }
100
101 let out = child.wait_with_output().await?;
103 debug!("Podman command exited: {:?}", out.status);
104 if !out.status.success() {
105 bail!(
106 "Podman command ({:?}) failed to execute: {:?}",
107 args,
108 out.status
109 );
110 }
111 Ok(out.stdout)
112}
113
114pub async fn pull(image: &str) -> Result<()> {
115 podman(&["image", "pull", "--", image], &ExecConfig::default()).await?;
116 Ok(())
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120#[serde(rename_all = "PascalCase")]
121pub struct Image {
122 pub digest: String,
123}
124
125pub async fn inspect(image: &str) -> Result<Image> {
126 let inspect = podman(
127 &["image", "inspect", "--", image],
128 &ExecConfig {
129 capture_stdout: true,
130 silence_stderr: true,
131 ..Default::default()
132 },
133 )
134 .await?;
135 let mut list = serde_json::from_slice::<Vec<Image>>(&inspect)?;
136 debug!("Image inspect result: {list:?}");
137
138 let inspect = list
139 .pop()
140 .with_context(|| anyhow!("Could not find any matching image: {image:?}"))?;
141
142 match list.len() {
143 0 => Ok(inspect),
144 len => bail!(
145 "The specified image is not canonical, inspect returned {}, expected 1",
146 len + 1
147 ),
148 }
149}
150
151#[derive(Debug)]
152pub struct Config<'a> {
153 pub mounts: &'a [(String, String)],
154 pub expose_fuse: bool,
155}
156
157#[derive(Debug, Default)]
158pub struct Exec<'a> {
159 pub capture_stdout: bool,
160 pub cwd: Option<&'a str>,
161 pub user: Option<&'a str>,
162 pub env: &'a [String],
163}
164
165#[derive(Debug)]
166pub struct Container {
167 pub id: String,
168}
169
170impl Container {
171 pub async fn create(image: &str, config: Config<'_>) -> Result<Container> {
172 let mut podman_args = vec![
173 "container".to_string(),
174 "run".to_string(),
175 "--detach".to_string(),
176 "--rm".to_string(),
177 "--network=host".to_string(),
178 "-v=/usr/bin/catatonit:/__:ro".to_string(),
179 "--entrypoint=/__".to_string(),
180 ];
181
182 for (src, dest) in config.mounts {
183 podman_args.push(format!("-v={src}:{dest}"));
184 }
185
186 if config.expose_fuse {
187 debug!("Mapping /dev/fuse into the container");
188 podman_args.push("--device=/dev/fuse".to_string());
189 }
190
191 podman_args.extend(["--".to_string(), image.to_string(), "-P".to_string()]);
192
193 debug!("Creating container...");
194 let mut out = podman(
195 &podman_args,
196 &ExecConfig {
197 capture_stdout: true,
198 ..Default::default()
199 },
200 )
201 .await?;
202 if let Some(idx) = memchr::memchr(b'\n', &out) {
203 out.truncate(idx);
204 }
205 let id = String::from_utf8(out)?;
206 Ok(Container { id })
207 }
208
209 pub async fn exec<I, S>(&self, args: I, options: Exec<'_>) -> Result<Vec<u8>>
210 where
211 I: IntoIterator<Item = S>,
212 S: AsRef<str> + fmt::Debug + Clone,
213 {
214 let args = args.into_iter().collect::<Vec<_>>();
215 let mut a = vec!["container".to_string(), "exec".to_string()];
216
217 if let Some(cwd) = options.cwd {
218 a.extend(["-w".to_string(), cwd.to_string()]);
219 }
220
221 if let Some(user) = options.user {
222 a.extend(["-u".to_string(), user.to_string()]);
223 }
224
225 for env in options.env {
226 a.extend(["-e".to_string(), env.to_string()]);
227 }
228
229 a.extend(["--".to_string(), self.id.to_string()]);
230 a.extend(args.iter().map(|x| x.as_ref().to_string()));
231 let buf = podman(
232 &a,
233 &ExecConfig {
234 capture_stdout: options.capture_stdout,
235 ..Default::default()
236 },
237 )
238 .await
239 .with_context(|| anyhow!("Failed to execute in container: {:?}", args))?;
240 Ok(buf)
241 }
242
243 pub async fn tar(&self, path: &str) -> Result<Vec<u8>> {
244 let a = vec![
245 "container".to_string(),
246 "cp".to_string(),
247 "--".to_string(),
248 format!("{}:{}", self.id, path),
249 "-".to_string(),
250 ];
251 let buf = podman(
252 &a,
253 &ExecConfig {
254 capture_stdout: true,
255 ..Default::default()
256 },
257 )
258 .await
259 .with_context(|| anyhow!("Failed to read from container: {:?}", path))?;
260
261 Ok(buf)
262 }
263
264 pub async fn cat(&self, path: &str) -> Result<Vec<u8>> {
265 let buf = self.tar(path).await?;
266
267 let mut tar = tar::Archive::new(&buf[..]);
268 let mut entries = tar.entries()?;
269 let entry = entries
270 .next()
271 .context("Tar archive generated by podman cp is empty")?;
272 let mut entry = entry?;
273
274 let entry_type = entry.header().entry_type();
275 if entry_type != tar::EntryType::Regular {
276 bail!("Extracted file is not of type file: {entry_type:?}");
277 }
278
279 let mut buf = Vec::new();
280 entry.read_to_end(&mut buf)?;
281
282 Ok(buf)
283 }
284
285 pub async fn write_file(&self, directory: &str, filename: &str, content: &[u8]) -> Result<()> {
286 let mut tar = tar::Builder::new(Vec::new());
288
289 let mut header = tar::Header::new_gnu();
290 header.set_size(content.len() as u64);
291 header.set_mode(0o640);
292
293 debug!(
294 "Adding to archive: {:?} ({} bytes)",
295 filename,
296 content.len()
297 );
298 tar.append_data(&mut header, filename, content)?;
299 let buf = tar.into_inner()?;
300
301 let a = vec![
303 "container".to_string(),
304 "cp".to_string(),
305 "--".to_string(),
306 "-".to_string(),
307 format!("{}:{}", self.id, directory),
308 ];
309 podman(
310 &a,
311 &ExecConfig {
312 stdin: Some(buf),
313 ..Default::default()
314 },
315 )
316 .await
317 .with_context(|| {
318 anyhow!("Failed to write container (directory={directory:?}, filename={filename:?}")
319 })?;
320
321 Ok(())
322 }
323
324 pub async fn kill(&self) -> Result<()> {
325 podman(
326 &["container", "kill", &self.id],
327 &ExecConfig {
328 capture_stdout: true,
329 ..Default::default()
330 },
331 )
332 .await
333 .context("Failed to remove container")?;
334 Ok(())
335 }
336
337 pub async fn run<F: Future<Output = Result<()>>>(&self, fut: F, keep: bool) -> Result<()> {
338 let fut = async {
339 fut.await?;
340 if keep {
341 info!("Keeping container around until ^C...");
342 future::pending().await
343 } else {
344 Ok(())
345 }
346 };
347 let result = tokio::select! {
348 result = fut => result,
349 _ = signal::ctrl_c() => Err(anyhow!("Ctrl-c received")),
350 };
351 debug!("Removing container...");
352 if let Err(err) = self.kill().await {
353 warn!("Failed to kill container {:?}: {:#}", self.id, err);
354 }
355 debug!("Container cleanup complete");
356 result
357 }
358}
359
360#[cfg(target_os = "linux")]
361pub fn test_userns_clone() -> Result<()> {
362 use nix::sched::CloneFlags;
363 use nix::sys::wait::{WaitPidFlag, WaitStatus};
364
365 let cb = Box::new(|| 0);
366 let stack = &mut [0; 1024];
367 let flags = CloneFlags::CLONE_NEWNS | CloneFlags::CLONE_NEWUSER;
368
369 let pid = unsafe { nix::sched::clone(cb, stack, flags, None) }
370 .context("Failed to create user namespace")?;
371 let status = nix::sys::wait::waitpid(pid, Some(WaitPidFlag::__WCLONE))
372 .context("Failed to reap child")?;
373
374 if status != WaitStatus::Exited(pid, 0) {
375 bail!("Unexpected wait result: {:?}", status);
376 }
377
378 Ok(())
379}
380
381#[cfg(target_os = "linux")]
382pub async fn test_for_unprivileged_userns_clone() -> Result<()> {
383 if std::env::var("REPRO_ENV_SKIP_CLONE_CHECK")
384 .map(|x| x != "0")
385 .unwrap_or(false)
386 {
387 debug!("Skipping test if user namespaces can be created");
388 return Ok(());
389 }
390
391 debug!("Testing if user namespaces can be created");
392 if let Err(err) = test_userns_clone() {
393 match tokio::fs::read("/proc/sys/kernel/unprivileged_userns_clone").await {
394 Ok(buf) => {
395 if buf == b"0\n" {
396 warn!("User namespaces are not enabled in /proc/sys/kernel/unprivileged_userns_clone")
397 }
398 }
399 Err(err) => warn!(
400 "Failed to check if unprivileged_userns_clone are allowed: {:#}",
401 err
402 ),
403 }
404
405 Err(err)
406 } else {
407 debug!("Successfully tested for user namespaces");
408 Ok(())
409 }
410}
411
412#[cfg(not(target_os = "linux"))]
413pub async fn test_for_unprivileged_userns_clone() -> Result<()> {
414 Ok(())
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_parse_image_ref() -> Result<()> {
423 let image_ref = ImageRef::from_str("rust")?;
424 assert_eq!(
425 image_ref,
426 ImageRef {
427 repo: "rust".to_string(),
428 tag: None,
429 digest: None,
430 }
431 );
432 Ok(())
433 }
434
435 #[test]
436 fn test_parse_image_ref_digest() -> Result<()> {
437 let image_ref = ImageRef::from_str(
438 "rust@sha256:28ee8822965a932e229599b59928f8c2655b2a198af30568acf63e8aff0e8a3a",
439 )?;
440 assert_eq!(
441 image_ref,
442 ImageRef {
443 repo: "rust".to_string(),
444 tag: None,
445 digest: Some(
446 "sha256:28ee8822965a932e229599b59928f8c2655b2a198af30568acf63e8aff0e8a3a"
447 .to_string()
448 ),
449 }
450 );
451 Ok(())
452 }
453
454 #[test]
455 fn test_parse_image_ref_tag() -> Result<()> {
456 let image_ref = ImageRef::from_str("rust:1-alpine3.18")?;
457 assert_eq!(
458 image_ref,
459 ImageRef {
460 repo: "rust".to_string(),
461 tag: Some("1-alpine3.18".to_string()),
462 digest: None,
463 }
464 );
465 Ok(())
466 }
467}