Skip to main content

arcbox_migration/
runner.rs

1//! Docker CLI transport used by migration planning and execution.
2
3use crate::docker_types::{
4    ContainerInspect, DockerInfo, ImageInspect, NetworkInspect, VolumeInspect,
5};
6use crate::error::{MigrationError, Result};
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::Arc;
10use tempfile::TempDir;
11use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
12use tokio::process::{Child, Command};
13
14use crate::helper_image::helper_image_reference;
15
16const HELPER_IMAGE_REFERENCE: &str = helper_image_reference();
17
18/// Docker CLI runner bound to a Unix socket.
19#[derive(Clone)]
20pub struct DockerCliRunner {
21    binary: PathBuf,
22    socket_path: PathBuf,
23    isolated_config: Arc<TempDir>,
24}
25
26impl std::fmt::Debug for DockerCliRunner {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("DockerCliRunner")
29            .field("binary", &self.binary)
30            .field("socket_path", &self.socket_path)
31            .finish_non_exhaustive()
32    }
33}
34
35/// Outcome of transferring one image between two daemons.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ImageTransfer {
38    /// Bytes streamed from `docker save` into `docker load`.
39    pub bytes: u64,
40    /// The ID the target assigned, reported only when the archive carried no
41    /// tags.
42    ///
43    /// Daemons backed by different image stores reassign IDs on load, so for
44    /// an untagged image this is the only reference that resolves afterwards.
45    pub loaded_image_id: Option<String>,
46}
47
48/// Extracts the target-assigned ID from `docker load` output.
49///
50/// `docker load` prints `Loaded image: <tag>` per tag, and falls back to
51/// `Loaded image ID: <id>` only when the archive has none. `--quiet`
52/// suppresses progress but keeps these summary lines.
53fn parse_loaded_image_id(stdout: &str) -> Option<String> {
54    stdout.lines().find_map(|line| {
55        line.trim()
56            .strip_prefix("Loaded image ID:")
57            .map(|id| id.trim().to_string())
58            .filter(|id| !id.is_empty())
59    })
60}
61
62/// Network creation options supported by the CLI transport.
63#[derive(Debug, Clone)]
64pub struct CreateNetworkOptions {
65    /// Whether the network is internal.
66    pub internal: bool,
67    /// Whether IPv6 is enabled.
68    pub enable_ipv6: bool,
69    /// Whether the network is attachable.
70    pub attachable: bool,
71    /// Network labels.
72    pub labels: Vec<(String, String)>,
73    /// Driver options.
74    pub options: Vec<(String, String)>,
75    /// IPAM subnet tuples.
76    pub ipam: Vec<(String, String, String)>,
77}
78
79impl DockerCliRunner {
80    /// Creates a new CLI runner for the provided Docker socket.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the `docker` binary cannot be located.
85    pub fn new(socket_path: impl Into<PathBuf>) -> Result<Self> {
86        Ok(Self {
87            binary: resolve_docker_binary().ok_or_else(|| {
88                MigrationError::Docker("failed to locate `docker` in PATH".into())
89            })?,
90            socket_path: socket_path.into(),
91            isolated_config: Arc::new(tempfile::tempdir()?),
92        })
93    }
94
95    /// Returns the socket path used by this runner.
96    #[must_use]
97    pub fn socket_path(&self) -> &Path {
98        &self.socket_path
99    }
100
101    /// Returns the helper image reference used for temporary volume containers.
102    #[must_use]
103    pub const fn helper_image_reference(&self) -> &'static str {
104        HELPER_IMAGE_REFERENCE
105    }
106
107    /// Returns Docker daemon info.
108    pub async fn info(&self) -> Result<DockerInfo> {
109        self.json_object(&["info", "--format", "{{json .}}"]).await
110    }
111
112    /// Returns all image inspect payloads.
113    pub async fn list_images(&self) -> Result<Vec<ImageInspect>> {
114        let ids = self.lines(&["image", "ls", "-aq", "--no-trunc"]).await?;
115        self.inspect_many::<ImageInspect>("image", &ids).await
116    }
117
118    /// Returns all volume inspect payloads.
119    pub async fn list_volumes(&self) -> Result<Vec<VolumeInspect>> {
120        let names = self.lines(&["volume", "ls", "-q"]).await?;
121        self.inspect_many::<VolumeInspect>("volume", &names).await
122    }
123
124    /// Returns all user-defined network inspect payloads.
125    pub async fn list_networks(&self) -> Result<Vec<NetworkInspect>> {
126        let ids = self
127            .lines(&["network", "ls", "--filter", "type=custom", "-q"])
128            .await?;
129        self.inspect_many::<NetworkInspect>("network", &ids).await
130    }
131
132    /// Returns all container inspect payloads, including stopped containers.
133    pub async fn list_containers(&self) -> Result<Vec<ContainerInspect>> {
134        let ids = self
135            .lines(&["container", "ls", "-aq", "--no-trunc"])
136            .await?;
137        self.inspect_many::<ContainerInspect>("container", &ids)
138            .await
139    }
140
141    /// Stops a container.
142    pub async fn stop_container(&self, id: &str) -> Result<()> {
143        self.status(["container", "stop", "--time", "30", id]).await
144    }
145
146    /// Starts a container.
147    pub async fn start_container(&self, id: &str) -> Result<()> {
148        self.status(["container", "start", id]).await
149    }
150
151    /// Removes a container forcibly.
152    pub async fn remove_container(&self, id: &str) -> Result<()> {
153        self.status(["container", "rm", "--force", "--volumes", id])
154            .await
155    }
156
157    /// Clears a helper container left behind by an interrupted run.
158    ///
159    /// A run whose daemon died mid-copy strands its helper, and
160    /// `docker create --name` refuses a duplicate — which would wedge the retry
161    /// the user is certain to attempt, since a failed migration leaves them no
162    /// other way to get their data across. Clearing the name first makes the
163    /// volume copy idempotent.
164    ///
165    /// Existence is checked rather than the removal being forced blindly, so a
166    /// genuine removal failure still surfaces instead of being swallowed as
167    /// "nothing was there". `remove_container` passes `--volumes`, which reaps
168    /// only *anonymous* volumes, so the named volume being migrated is never at
169    /// risk.
170    pub async fn remove_stale_helper(&self, name: &str) -> Result<()> {
171        if !self.container_exists(name).await {
172            return Ok(());
173        }
174        self.remove_container(name).await
175    }
176
177    /// Whether a container with this name exists, running or not.
178    async fn container_exists(&self, name: &str) -> bool {
179        self.command()
180            .args(["container", "inspect", name])
181            .stdout(Stdio::null())
182            .stderr(Stdio::null())
183            .status()
184            .await
185            .is_ok_and(|status| status.success())
186    }
187
188    /// Removes a volume.
189    pub async fn remove_volume(&self, name: &str) -> Result<()> {
190        self.status(["volume", "rm", "--force", name]).await
191    }
192
193    /// Removes a network.
194    pub async fn remove_network(&self, name: &str) -> Result<()> {
195        self.status(["network", "rm", name]).await
196    }
197
198    /// Creates a volume with labels and options.
199    pub async fn create_volume(
200        &self,
201        name: &str,
202        labels: &[(String, String)],
203        options: &[(String, String)],
204    ) -> Result<()> {
205        let mut args = vec!["volume".to_string(), "create".to_string(), name.to_string()];
206        for (key, value) in labels {
207            args.push("--label".to_string());
208            args.push(format!("{key}={value}"));
209        }
210        for (key, value) in options {
211            args.push("--opt".to_string());
212            args.push(format!("{key}={value}"));
213        }
214        self.status_owned(args).await
215    }
216
217    /// Creates a network using supported bridge-network flags.
218    pub async fn create_network(&self, name: &str, config: &CreateNetworkOptions) -> Result<()> {
219        let mut args = vec![
220            "network".to_string(),
221            "create".to_string(),
222            "--driver".to_string(),
223            "bridge".to_string(),
224        ];
225        if config.internal {
226            args.push("--internal".to_string());
227        }
228        if config.enable_ipv6 {
229            args.push("--ipv6".to_string());
230        }
231        if config.attachable {
232            args.push("--attachable".to_string());
233        }
234        for (key, value) in &config.labels {
235            args.push("--label".to_string());
236            args.push(format!("{key}={value}"));
237        }
238        for (key, value) in &config.options {
239            args.push("--opt".to_string());
240            args.push(format!("{key}={value}"));
241        }
242        for (subnet, gateway, ip_range) in &config.ipam {
243            if !subnet.is_empty() {
244                args.push("--subnet".to_string());
245                args.push(subnet.clone());
246            }
247            if !gateway.is_empty() {
248                args.push("--gateway".to_string());
249                args.push(gateway.clone());
250            }
251            if !ip_range.is_empty() {
252                args.push("--ip-range".to_string());
253                args.push(ip_range.clone());
254            }
255        }
256        args.push(name.to_string());
257        self.status_owned(args).await
258    }
259
260    /// Creates a container and returns its ID.
261    pub async fn create_container<I, S>(&self, args: I) -> Result<String>
262    where
263        I: IntoIterator<Item = S>,
264        S: AsRef<str>,
265    {
266        let output = self.output(["container", "create"], args).await?;
267        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
268    }
269
270    /// Connects a container to an additional network.
271    pub async fn connect_network(
272        &self,
273        network: &str,
274        container: &str,
275        aliases: &[String],
276    ) -> Result<()> {
277        let mut args = vec!["network".to_string(), "connect".to_string()];
278        for alias in aliases {
279            args.push("--alias".to_string());
280            args.push(alias.clone());
281        }
282        args.push(network.to_string());
283        args.push(container.to_string());
284        self.status_owned(args).await
285    }
286
287    /// Creates a helper container mounting the provided volume at `/volume`.
288    pub async fn create_helper_container(&self, name: &str, volume_name: &str) -> Result<String> {
289        self.create_container([
290            "--name",
291            name,
292            "--mount",
293            &format!("type=volume,src={volume_name},dst=/volume"),
294            HELPER_IMAGE_REFERENCE,
295            "/helper",
296        ])
297        .await
298    }
299
300    /// Ensures the helper image exists by importing an empty tar archive when needed.
301    pub async fn ensure_helper_image(&self) -> Result<()> {
302        let status = Command::new(&self.binary)
303            .arg("--host")
304            .arg(self.host_arg())
305            .args(["image", "inspect", HELPER_IMAGE_REFERENCE])
306            .env("DOCKER_CONFIG", self.isolated_config.path())
307            .env("DOCKER_CLI_HINTS", "false")
308            .stdout(Stdio::null())
309            .stderr(Stdio::null())
310            .status()
311            .await?;
312        if status.success() {
313            return Ok(());
314        }
315
316        let mut child = self
317            .command()
318            .args(["image", "import", "-", HELPER_IMAGE_REFERENCE])
319            .stdin(Stdio::piped())
320            .stdout(Stdio::null())
321            .stderr(Stdio::piped())
322            .spawn()?;
323        if let Some(mut stdin) = child.stdin.take() {
324            stdin.write_all(&empty_tar_bytes()).await?;
325        }
326        wait_for_success(child, "docker image import").await
327    }
328
329    /// Streams `docker save` on this daemon straight into `docker load` on
330    /// `target`.
331    ///
332    /// `references` must list every tag to preserve: `docker save` keeps all
333    /// tags of an image only for arguments given without a tag, so naming a
334    /// single `repo:tag` silently drops that image's other tags.
335    pub async fn pipe_save_into(
336        &self,
337        target: &Self,
338        references: &[String],
339    ) -> Result<ImageTransfer> {
340        if references.is_empty() {
341            return Err(MigrationError::InvalidPlan(
342                "image plan has no export references".into(),
343            ));
344        }
345
346        let mut save = self
347            .command()
348            .args(["image", "save"])
349            .args(references)
350            .stdout(Stdio::piped())
351            .stderr(Stdio::piped())
352            .spawn()?;
353        let mut load = target
354            .command()
355            .args(["image", "load", "--quiet"])
356            .stdin(Stdio::piped())
357            .stdout(Stdio::piped())
358            .stderr(Stdio::piped())
359            .spawn()?;
360
361        let mut save_stdout = save
362            .stdout
363            .take()
364            .ok_or_else(|| MigrationError::Docker("docker save stdout missing".into()))?;
365        let mut load_stdin = load
366            .stdin
367            .take()
368            .ok_or_else(|| MigrationError::Docker("docker load stdin missing".into()))?;
369
370        let copy_task = tokio::spawn(async move {
371            let copied = tokio::io::copy(&mut save_stdout, &mut load_stdin).await?;
372            load_stdin.shutdown().await?;
373            Ok::<u64, std::io::Error>(copied)
374        });
375        // Drain every remaining pipe concurrently to prevent buffer deadlocks.
376        let save_stderr = tokio::spawn(take_stderr(save.stderr.take()));
377        let load_stderr = tokio::spawn(take_stderr(load.stderr.take()));
378        let load_stdout = tokio::spawn(take_pipe(load.stdout.take(), "docker load stdout"));
379
380        let save_status = save.wait().await?;
381        let load_status = load.wait().await?;
382        let copied = copy_task
383            .await
384            .map_err(|e| MigrationError::Docker(format!("docker save copy task failed: {e}")))?;
385        let save_stderr = save_stderr.await.map_err(|e| {
386            MigrationError::Docker(format!("docker save stderr task failed: {e}"))
387        })??;
388        let load_stderr = load_stderr.await.map_err(|e| {
389            MigrationError::Docker(format!("docker load stderr task failed: {e}"))
390        })??;
391        let load_stdout = load_stdout.await.map_err(|e| {
392            MigrationError::Docker(format!("docker load stdout task failed: {e}"))
393        })??;
394
395        // Report the producing side first: a `docker load` failure is usually a
396        // downstream symptom of `docker save` dying mid-stream.
397        if !save_status.success() {
398            return Err(MigrationError::Docker(format!(
399                "docker image save failed: {}",
400                save_stderr.trim()
401            )));
402        }
403        if !load_status.success() {
404            return Err(MigrationError::Docker(format!(
405                "docker image load failed: {}",
406                load_stderr.trim()
407            )));
408        }
409        Ok(ImageTransfer {
410            bytes: copied?,
411            loaded_image_id: parse_loaded_image_id(&load_stdout),
412        })
413    }
414
415    /// Streams a container path into a tempfile via `docker cp`.
416    ///
417    /// `--archive` asks for source ownership explicitly. Note it is belt and
418    /// braces here, not a fix: the `-` stream forms already round-trip uid/gid
419    /// (measured 2026-08-01 — a `1000:1000` volume file survived a migration
420    /// on a build without this flag). The documented "ownership is set at the
421    /// destination" rule applies to path-to-path copies, not to tar streams.
422    pub async fn copy_from_container(
423        &self,
424        container: &str,
425        source_path: &str,
426    ) -> Result<tempfile::NamedTempFile> {
427        let file = tempfile::NamedTempFile::new()?;
428        let path = file.path().to_path_buf();
429        let mut child = self
430            .command()
431            .args([
432                "container",
433                "cp",
434                "--archive",
435                &format!("{container}:{source_path}"),
436                "-",
437            ])
438            .stdout(Stdio::piped())
439            .stderr(Stdio::piped())
440            .spawn()?;
441
442        let mut stdout = child
443            .stdout
444            .take()
445            .ok_or_else(|| MigrationError::Docker("docker cp stdout missing".into()))?;
446        let mut dest = tokio::fs::File::create(&path).await?;
447        let stdout_task =
448            tokio::spawn(async move { tokio::io::copy(&mut stdout, &mut dest).await.map(|_| ()) });
449        // Drain stderr concurrently to prevent pipe buffer deadlocks.
450        let stderr_task = tokio::spawn(take_stderr(child.stderr.take()));
451        let status = child.wait().await?;
452        stdout_task
453            .await
454            .map_err(|e| MigrationError::Docker(format!("docker cp copy task failed: {e}")))??;
455        let stderr = stderr_task
456            .await
457            .map_err(|e| MigrationError::Docker(format!("docker cp stderr task failed: {e}")))??;
458        if !status.success() {
459            return Err(MigrationError::Docker(format!(
460                "docker container cp failed: {}",
461                stderr.trim()
462            )));
463        }
464        Ok(file)
465    }
466
467    /// Streams a tar archive tempfile into a container via `docker cp`.
468    ///
469    /// See [`Self::copy_from_container`] for what `--archive` does and does
470    /// not buy here.
471    pub async fn copy_to_container(
472        &self,
473        source_archive: &Path,
474        container: &str,
475        target_path: &str,
476    ) -> Result<()> {
477        let mut child = self
478            .command()
479            .args([
480                "container",
481                "cp",
482                "--archive",
483                "-",
484                &format!("{container}:{target_path}"),
485            ])
486            .stdin(Stdio::piped())
487            .stdout(Stdio::null())
488            .stderr(Stdio::piped())
489            .spawn()?;
490        let mut stdin = child
491            .stdin
492            .take()
493            .ok_or_else(|| MigrationError::Docker("docker cp stdin missing".into()))?;
494        let mut source = tokio::fs::File::open(source_archive).await?;
495        let write_task = tokio::spawn(async move {
496            tokio::io::copy(&mut source, &mut stdin).await?;
497            stdin.shutdown().await
498        });
499        // Drain stderr concurrently to prevent pipe buffer deadlocks.
500        let stderr_task = tokio::spawn(take_stderr(child.stderr.take()));
501        let status = child.wait().await?;
502        write_task
503            .await
504            .map_err(|e| MigrationError::Docker(format!("docker cp write task failed: {e}")))??;
505        let stderr = stderr_task
506            .await
507            .map_err(|e| MigrationError::Docker(format!("docker cp stderr task failed: {e}")))??;
508        if !status.success() {
509            return Err(MigrationError::Docker(format!(
510                "docker container cp failed: {}",
511                stderr.trim()
512            )));
513        }
514        Ok(())
515    }
516
517    async fn inspect_many<T>(&self, noun: &str, ids: &[String]) -> Result<Vec<T>>
518    where
519        T: serde::de::DeserializeOwned,
520    {
521        if ids.is_empty() {
522            return Ok(Vec::new());
523        }
524        let mut args = vec![noun.to_string(), "inspect".to_string()];
525        args.extend(ids.iter().cloned());
526        self.json_array_owned(args).await
527    }
528
529    async fn json_object<T>(&self, args: &[&str]) -> Result<T>
530    where
531        T: serde::de::DeserializeOwned,
532    {
533        let output = self
534            .output_owned(args.iter().map(ToString::to_string).collect())
535            .await?;
536        serde_json::from_slice(&output.stdout).map_err(Into::into)
537    }
538
539    async fn json_array_owned<T>(&self, args: Vec<String>) -> Result<Vec<T>>
540    where
541        T: serde::de::DeserializeOwned,
542    {
543        let output = self.output_owned(args).await?;
544        serde_json::from_slice(&output.stdout).map_err(Into::into)
545    }
546
547    async fn lines(&self, args: &[&str]) -> Result<Vec<String>> {
548        let output = self
549            .output_owned(args.iter().map(ToString::to_string).collect())
550            .await?;
551        Ok(String::from_utf8_lossy(&output.stdout)
552            .lines()
553            .map(str::trim)
554            .filter(|line| !line.is_empty())
555            .map(ToOwned::to_owned)
556            .collect())
557    }
558
559    async fn status<I, S>(&self, args: I) -> Result<()>
560    where
561        I: IntoIterator<Item = S>,
562        S: AsRef<str>,
563    {
564        self.output_owned(
565            args.into_iter()
566                .map(|arg| arg.as_ref().to_string())
567                .collect(),
568        )
569        .await
570        .map(|_| ())
571    }
572
573    async fn status_owned(&self, args: Vec<String>) -> Result<()> {
574        self.output_owned(args).await.map(|_| ())
575    }
576
577    async fn output<I, S, J, T>(&self, prefix: I, rest: J) -> Result<std::process::Output>
578    where
579        I: IntoIterator<Item = S>,
580        S: AsRef<str>,
581        J: IntoIterator<Item = T>,
582        T: AsRef<str>,
583    {
584        let mut args: Vec<String> = prefix
585            .into_iter()
586            .map(|item| item.as_ref().to_string())
587            .collect();
588        args.extend(rest.into_iter().map(|item| item.as_ref().to_string()));
589        self.output_owned(args).await
590    }
591
592    async fn output_owned(&self, args: Vec<String>) -> Result<std::process::Output> {
593        let output = self
594            .command()
595            .args(args)
596            .output()
597            .await
598            .map_err(|e| MigrationError::Docker(format!("failed to run docker: {e}")))?;
599        if output.status.success() {
600            return Ok(output);
601        }
602        Err(MigrationError::Docker(
603            String::from_utf8_lossy(&output.stderr).trim().to_string(),
604        ))
605    }
606
607    fn command(&self) -> Command {
608        let mut command = Command::new(&self.binary);
609        command
610            .arg("--host")
611            .arg(self.host_arg())
612            .env("DOCKER_CONFIG", self.isolated_config.path())
613            .env("DOCKER_CLI_HINTS", "false")
614            .env("NO_COLOR", "1")
615            .kill_on_drop(true);
616        command
617    }
618
619    fn host_arg(&self) -> String {
620        format!("unix://{}", self.socket_path.display())
621    }
622}
623
624async fn wait_for_success(mut child: Child, context: &str) -> Result<()> {
625    let stderr = take_stderr(child.stderr.take()).await?;
626    let status = child.wait().await?;
627    if status.success() {
628        Ok(())
629    } else {
630        Err(MigrationError::Docker(format!(
631            "{context} failed: {}",
632            stderr.trim()
633        )))
634    }
635}
636
637/// Reads a child pipe to end of stream as lossy UTF-8.
638async fn take_pipe<R>(pipe: Option<R>, what: &'static str) -> Result<String>
639where
640    R: tokio::io::AsyncRead + Unpin + Send,
641{
642    let mut pipe = pipe.ok_or_else(|| MigrationError::Docker(format!("{what} pipe missing")))?;
643    let mut buf = Vec::new();
644    pipe.read_to_end(&mut buf).await?;
645    Ok(String::from_utf8_lossy(&buf).to_string())
646}
647
648async fn take_stderr(stderr: Option<tokio::process::ChildStderr>) -> Result<String> {
649    take_pipe(stderr, "docker stderr").await
650}
651
652fn resolve_docker_binary() -> Option<PathBuf> {
653    if let Some(path) = find_in_path("docker") {
654        return Some(path);
655    }
656
657    let home = dirs::home_dir()?;
658    let candidates = [
659        home.join(".arcbox/bin/docker"),
660        home.join(".arcbox/runtime/bin/docker"),
661        PathBuf::from("/opt/homebrew/bin/docker"),
662        PathBuf::from("/usr/local/bin/docker"),
663        PathBuf::from("/Applications/Docker.app/Contents/Resources/bin/docker"),
664    ];
665    candidates.into_iter().find(|path: &PathBuf| path.is_file())
666}
667
668fn find_in_path(binary: &str) -> Option<PathBuf> {
669    let path_var = std::env::var_os("PATH")?;
670    for directory in std::env::split_paths(&path_var) {
671        let candidate = directory.join(binary);
672        if candidate.is_file() {
673            return Some(candidate);
674        }
675    }
676    None
677}
678
679fn empty_tar_bytes() -> Vec<u8> {
680    vec![0; 1024]
681}
682
683#[cfg(test)]
684mod tests {
685    use super::{empty_tar_bytes, find_in_path, parse_loaded_image_id};
686
687    #[test]
688    fn untagged_load_reports_the_assigned_id() {
689        // Verbatim `docker load --quiet` output for a tagless archive.
690        let stdout = "Loaded image ID: sha256:1c8e3d999f27cb62d9714d9226751e16bacab4227\n";
691        assert_eq!(
692            parse_loaded_image_id(stdout).as_deref(),
693            Some("sha256:1c8e3d999f27cb62d9714d9226751e16bacab4227")
694        );
695    }
696
697    #[test]
698    fn tagged_load_reports_no_id_to_remap() {
699        // Tagged archives print `Loaded image:` instead; containers reference
700        // those by tag, so there is nothing to rewrite.
701        let stdout = "Loaded image: myapp:dev\nLoaded image: myapp:latest\n";
702        assert_eq!(parse_loaded_image_id(stdout), None);
703        assert_eq!(parse_loaded_image_id(""), None);
704        assert_eq!(parse_loaded_image_id("Loaded image ID:   \n"), None);
705    }
706
707    #[test]
708    fn empty_tar_has_two_zero_blocks() {
709        assert_eq!(empty_tar_bytes().len(), 1024);
710        assert!(empty_tar_bytes().iter().all(|byte| *byte == 0));
711    }
712
713    #[test]
714    fn path_lookup_handles_missing_binary() {
715        assert!(find_in_path("definitely-not-a-real-binary").is_none());
716    }
717}