ostree_ext/tar/
write.rs

1//! APIs to write a tarball stream into an OSTree commit.
2//!
3//! This functionality already exists in libostree mostly,
4//! this API adds a higher level, more ergonomic Rust frontend
5//! to it.
6//!
7//! In the future, this may also evolve into parsing the tar
8//! stream in Rust, not in C.
9
10use crate::Result;
11use anyhow::{anyhow, Context};
12use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
13
14use cap_std::io_lifetimes;
15use cap_std_ext::cap_std::fs::Dir;
16use cap_std_ext::cmdext::CapStdExtCommandExt;
17use cap_std_ext::{cap_std, cap_tempfile};
18use containers_image_proxy::oci_spec::image as oci_image;
19use fn_error_context::context;
20use ostree::gio;
21use ostree::prelude::FileExt;
22use std::borrow::Cow;
23use std::collections::{BTreeMap, HashMap};
24use std::io::{BufWriter, Seek, Write};
25use std::path::Path;
26use std::process::Stdio;
27use std::sync::Arc;
28use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
29use tracing::instrument;
30
31// Exclude things in https://www.freedesktop.org/wiki/Software/systemd/APIFileSystems/
32// from being placed in the rootfs.
33const EXCLUDED_TOPLEVEL_PATHS: &[&str] = &["run", "tmp", "proc", "sys", "dev"];
34
35/// Copy a tar entry to a new tar archive, optionally using a different filesystem path.
36#[context("Copying entry")]
37pub(crate) fn copy_entry(
38    mut entry: tar::Entry<impl std::io::Read>,
39    dest: &mut tar::Builder<impl std::io::Write>,
40    path: Option<&Path>,
41) -> Result<()> {
42    // Make copies of both the header and path, since that's required for the append APIs
43    let path = if let Some(path) = path {
44        path.to_owned()
45    } else {
46        (*entry.path()?).to_owned()
47    };
48    let mut header = entry.header().clone();
49    if let Some(headers) = entry.pax_extensions()? {
50        let extensions = headers
51            .map(|ext| {
52                let ext = ext?;
53                Ok((ext.key()?, ext.value_bytes()))
54            })
55            .collect::<Result<Vec<_>>>()?;
56        dest.append_pax_extensions(extensions.as_slice().iter().copied())?;
57    }
58
59    // Need to use the entry.link_name() not the header.link_name()
60    // api as the header api does not handle long paths:
61    // https://github.com/alexcrichton/tar-rs/issues/192
62    match entry.header().entry_type() {
63        tar::EntryType::Symlink => {
64            let target = entry.link_name()?.ok_or_else(|| anyhow!("Invalid link"))?;
65            // Sanity check UTF-8 here too.
66            let target: &Utf8Path = (&*target).try_into()?;
67            dest.append_link(&mut header, path, target)
68        }
69        tar::EntryType::Link => {
70            let target = entry.link_name()?.ok_or_else(|| anyhow!("Invalid link"))?;
71            let target: &Utf8Path = (&*target).try_into()?;
72            // We need to also normalize the target in order to handle hardlinked files in /etc
73            // where we remap /etc to /usr/etc.
74            let target = remap_etc_path(target);
75            dest.append_link(&mut header, path, &*target)
76        }
77        _ => dest.append_data(&mut header, path, entry),
78    }
79    .map_err(Into::into)
80}
81
82/// Configuration for tar layer commits.
83#[derive(Debug, Default)]
84#[non_exhaustive]
85pub struct WriteTarOptions {
86    /// Base ostree commit hash
87    pub base: Option<String>,
88    /// Enable SELinux labeling from the base commit
89    /// Requires the `base` option.
90    pub selinux: bool,
91    /// Allow content not in /usr; this should be paired with ostree rootfs.transient = true
92    pub allow_nonusr: bool,
93    /// If true, do not move content in /var to /usr/share/factory/var.  This should be used
94    /// with ostree v2024.3 or newer.
95    pub retain_var: bool,
96}
97
98/// The result of writing a tar stream.
99///
100/// This includes some basic data on the number of files that were filtered
101/// out because they were not in `/usr`.
102#[derive(Debug, Default)]
103pub struct WriteTarResult {
104    /// The resulting OSTree commit SHA-256.
105    pub commit: String,
106    /// Number of paths in a prefix (e.g. `/var` or `/boot`) which were discarded.
107    pub filtered: BTreeMap<String, u32>,
108}
109
110// Copy of logic from https://github.com/ostreedev/ostree/pull/2447
111// to avoid waiting for backport + releases
112fn sepolicy_from_base(repo: &ostree::Repo, base: &str) -> Result<tempfile::TempDir> {
113    let cancellable = gio::Cancellable::NONE;
114    let policypath = "usr/etc/selinux";
115    let tempdir = tempfile::tempdir()?;
116    let (root, _) = repo.read_commit(base, cancellable)?;
117    let policyroot = root.resolve_relative_path(policypath);
118    if policyroot.query_exists(cancellable) {
119        let policydest = tempdir.path().join(policypath);
120        std::fs::create_dir_all(policydest.parent().unwrap())?;
121        let opts = ostree::RepoCheckoutAtOptions {
122            mode: ostree::RepoCheckoutMode::User,
123            subpath: Some(Path::new(policypath).to_owned()),
124            ..Default::default()
125        };
126        repo.checkout_at(Some(&opts), ostree::AT_FDCWD, policydest, base, cancellable)?;
127    }
128    Ok(tempdir)
129}
130
131#[derive(Debug, PartialEq, Eq)]
132enum NormalizedPathResult<'a> {
133    Filtered(&'a str),
134    Normal(Utf8PathBuf),
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Default)]
138pub(crate) struct TarImportConfig {
139    allow_nonusr: bool,
140    remap_factory_var: bool,
141}
142
143// If a path starts with /etc or ./etc or etc, remap it to be usr/etc.
144fn remap_etc_path(path: &Utf8Path) -> Cow<Utf8Path> {
145    let mut components = path.components();
146    let Some(prefix) = components.next() else {
147        return Cow::Borrowed(path);
148    };
149    let (prefix, first) = if matches!(prefix, Utf8Component::CurDir | Utf8Component::RootDir) {
150        let Some(next) = components.next() else {
151            return Cow::Borrowed(path);
152        };
153        (Some(prefix), next)
154    } else {
155        (None, prefix)
156    };
157    if first.as_str() == "etc" {
158        let usr = Utf8Component::Normal("usr");
159        Cow::Owned(
160            prefix
161                .into_iter()
162                .chain([usr, first])
163                .chain(components)
164                .collect(),
165        )
166    } else {
167        Cow::Borrowed(path)
168    }
169}
170
171fn normalize_validate_path<'a>(
172    path: &'a Utf8Path,
173    config: &'_ TarImportConfig,
174) -> Result<NormalizedPathResult<'a>> {
175    // This converts e.g. `foo//bar/./baz` into `foo/bar/baz`.
176    let mut components = path
177        .components()
178        .map(|part| {
179            match part {
180                // Convert absolute paths to relative
181                camino::Utf8Component::RootDir => Ok(camino::Utf8Component::CurDir),
182                // Allow ./ and regular parts
183                camino::Utf8Component::Normal(_) | camino::Utf8Component::CurDir => Ok(part),
184                // Barf on Windows paths as well as Unix path uplinks `..`
185                _ => Err(anyhow!("Invalid path: {}", path)),
186            }
187        })
188        .peekable();
189    let mut ret = Utf8PathBuf::new();
190    // Insert a leading `./` if not present
191    if let Some(Ok(camino::Utf8Component::Normal(_))) = components.peek() {
192        ret.push(camino::Utf8Component::CurDir);
193    }
194    let mut found_first = false;
195    let mut excluded = false;
196    for part in components {
197        let part = part?;
198        if excluded {
199            return Ok(NormalizedPathResult::Filtered(part.as_str()));
200        }
201        if !found_first {
202            if let Utf8Component::Normal(part) = part {
203                found_first = true;
204                match part {
205                    // We expect all the OS content to live in usr in general
206                    "usr" => ret.push(part),
207                    // ostree has special support for /etc
208                    "etc" => {
209                        ret.push("usr/etc");
210                    }
211                    "var" => {
212                        // Content in /var will get copied by a systemd tmpfiles.d unit
213                        if config.remap_factory_var {
214                            ret.push("usr/share/factory/var");
215                        } else {
216                            ret.push(part)
217                        }
218                    }
219                    o if EXCLUDED_TOPLEVEL_PATHS.contains(&o) => {
220                        // We don't want to actually drop the toplevel, but mark
221                        // *children* of it as excluded.
222                        excluded = true;
223                        ret.push(part)
224                    }
225                    _ if config.allow_nonusr => ret.push(part),
226                    _ => {
227                        return Ok(NormalizedPathResult::Filtered(part));
228                    }
229                }
230            } else {
231                ret.push(part);
232            }
233        } else {
234            ret.push(part);
235        }
236    }
237
238    Ok(NormalizedPathResult::Normal(ret))
239}
240
241/// Perform various filtering on imported tar archives.
242///  - Move /etc to /usr/etc
243///  - Entirely drop files not in /usr
244///
245/// This also acts as a Rust "pre-parser" of the tar archive, hopefully
246/// catching anything corrupt that might be exploitable from the C libarchive side.
247/// Remember that we're parsing this while we're downloading it, and in order
248/// to verify integrity we rely on the total sha256 of the blob, so all content
249/// written before then must be considered untrusted.
250pub(crate) fn filter_tar(
251    src: impl std::io::Read,
252    dest: impl std::io::Write,
253    config: &TarImportConfig,
254    tmpdir: &Dir,
255) -> Result<BTreeMap<String, u32>> {
256    let src = std::io::BufReader::new(src);
257    let mut src = tar::Archive::new(src);
258    let dest = BufWriter::new(dest);
259    let mut dest = tar::Builder::new(dest);
260    let mut filtered = BTreeMap::new();
261
262    let ents = src.entries()?;
263
264    tracing::debug!("Filtering tar; config={config:?}");
265
266    // Lookaside data for dealing with hardlinked files into /sysroot; see below.
267    let mut changed_sysroot_objects = HashMap::new();
268    let mut new_sysroot_link_targets = HashMap::<Utf8PathBuf, Utf8PathBuf>::new();
269
270    for entry in ents {
271        let mut entry = entry?;
272        let header = entry.header();
273        let path = entry.path()?;
274        let path: &Utf8Path = (&*path).try_into()?;
275        // Force all paths to relative
276        let path = path.strip_prefix("/").unwrap_or(path);
277
278        let is_modified = header.mtime().unwrap_or_default() > 0;
279        let is_regular = header.entry_type() == tar::EntryType::Regular;
280        if path.strip_prefix(crate::tar::REPO_PREFIX).is_ok() {
281            // If it's a modified file in /sysroot, it may be a target for future hardlinks.
282            // In that case, we copy the data off to a temporary file.  Then the first hardlink
283            // to it becomes instead the real file, and any *further* hardlinks refer to that
284            // file instead.
285            if is_modified && is_regular {
286                tracing::debug!("Processing modified sysroot file {path}");
287                // Create an O_TMPFILE (anonymous file) to use as a temporary store for the file data
288                let mut tmpf = cap_tempfile::TempFile::new_anonymous(tmpdir)
289                    .map(BufWriter::new)
290                    .context("Creating tmpfile")?;
291                let path = path.to_owned();
292                let header = header.clone();
293                std::io::copy(&mut entry, &mut tmpf)
294                    .map_err(anyhow::Error::msg)
295                    .context("Copying")?;
296                let mut tmpf = tmpf.into_inner()?;
297                tmpf.seek(std::io::SeekFrom::Start(0))?;
298                // Cache this data, indexed by the file path
299                changed_sysroot_objects.insert(path, (header, tmpf));
300                continue;
301            }
302        } else if header.entry_type() == tar::EntryType::Link && is_modified {
303            let target = header
304                .link_name()?
305                .ok_or_else(|| anyhow!("Invalid empty hardlink"))?;
306            let target: &Utf8Path = (&*target).try_into()?;
307            // Canonicalize to a relative path
308            let target = path.strip_prefix("/").unwrap_or(target);
309            // If this is a hardlink into /sysroot...
310            if target.strip_prefix(crate::tar::REPO_PREFIX).is_ok() {
311                // And we found a previously processed modified file there
312                if let Some((mut header, data)) = changed_sysroot_objects.remove(target) {
313                    tracing::debug!("Making {path} canonical for sysroot link {target}");
314                    // Make *this* entry the canonical one, consuming the temporary file data
315                    dest.append_data(&mut header, path, data)?;
316                    // And cache this file path as the new link target
317                    new_sysroot_link_targets.insert(target.to_owned(), path.to_owned());
318                } else if let Some(real_target) = new_sysroot_link_targets.get(target) {
319                    tracing::debug!("Relinking {path} to {real_target}");
320                    // We found a 2nd (or 3rd, etc.) link into /sysroot; rewrite the link
321                    // target to be the first file outside of /sysroot we found.
322                    let mut header = header.clone();
323                    dest.append_link(&mut header, path, real_target)?;
324                } else {
325                    tracing::debug!("Found unhandled modified link from {path} to {target}");
326                }
327                continue;
328            }
329        }
330
331        let normalized = match normalize_validate_path(path, config)? {
332            NormalizedPathResult::Filtered(path) => {
333                tracing::trace!("Filtered: {path}");
334                if let Some(v) = filtered.get_mut(path) {
335                    *v += 1;
336                } else {
337                    filtered.insert(path.to_string(), 1);
338                }
339                continue;
340            }
341            NormalizedPathResult::Normal(path) => path,
342        };
343
344        copy_entry(entry, &mut dest, Some(normalized.as_std_path()))?;
345    }
346    dest.into_inner()?.flush()?;
347    Ok(filtered)
348}
349
350/// Asynchronous wrapper for filter_tar()
351#[context("Filtering tar stream")]
352async fn filter_tar_async(
353    src: impl AsyncRead + Send + 'static,
354    media_type: oci_image::MediaType,
355    mut dest: impl AsyncWrite + Send + Unpin,
356    config: &TarImportConfig,
357    repo_tmpdir: Dir,
358) -> Result<BTreeMap<String, u32>> {
359    let (tx_buf, mut rx_buf) = tokio::io::duplex(8192);
360    // The source must be moved to the heap so we know it is stable for passing to the worker thread
361    let src = Box::pin(src);
362    let config = config.clone();
363    let tar_transformer = crate::tokio_util::spawn_blocking_flatten(move || {
364        let src = tokio_util::io::SyncIoBridge::new(src);
365        let mut src = crate::container::decompressor(&media_type, src)?;
366        let dest = tokio_util::io::SyncIoBridge::new(tx_buf);
367
368        let r = filter_tar(&mut src, dest, &config, &repo_tmpdir);
369        // Pass ownership of the input stream back to the caller - see below.
370        Ok((r, src))
371    });
372    let copier = tokio::io::copy(&mut rx_buf, &mut dest);
373    let (r, v) = tokio::join!(tar_transformer, copier);
374    let _v: u64 = v?;
375    let (r, src) = r?;
376    // Note that the worker thread took temporary ownership of the input stream; we only close
377    // it at this point, after we're sure we've done all processing of the input.  The reason
378    // for this is that both the skopeo process *or* us could encounter an error (see join_fetch).
379    // By ensuring we hold the stream open as long as possible, it ensures that we're going to
380    // see a remote error first, instead of the remote skopeo process seeing us close the pipe
381    // because we found an error.
382    drop(src);
383    // And pass back the result
384    r
385}
386
387/// Write the contents of a tarball as an ostree commit.
388#[allow(unsafe_code)] // For raw fd bits
389#[instrument(level = "debug", skip_all)]
390pub async fn write_tar(
391    repo: &ostree::Repo,
392    src: impl tokio::io::AsyncRead + Send + Unpin + 'static,
393    media_type: oci_image::MediaType,
394    refname: &str,
395    options: Option<WriteTarOptions>,
396) -> Result<WriteTarResult> {
397    let repo = repo.clone();
398    let options = options.unwrap_or_default();
399    let sepolicy = if options.selinux {
400        if let Some(base) = options.base {
401            Some(sepolicy_from_base(&repo, &base).context("tar: Preparing sepolicy")?)
402        } else {
403            None
404        }
405    } else {
406        None
407    };
408    let mut c = std::process::Command::new("ostree");
409    let repofd = repo.dfd_as_file()?;
410    let repofd: Arc<io_lifetimes::OwnedFd> = Arc::new(repofd.into());
411    {
412        let c = c
413            .stdin(Stdio::piped())
414            .stdout(Stdio::piped())
415            .stderr(Stdio::piped())
416            .args(["commit"]);
417        c.take_fd_n(repofd.clone(), 3);
418        c.arg("--repo=/proc/self/fd/3");
419        if let Some(sepolicy) = sepolicy.as_ref() {
420            c.arg("--selinux-policy");
421            c.arg(sepolicy.path());
422        }
423        c.arg(format!(
424            "--add-metadata-string=ostree.importer.version={}",
425            env!("CARGO_PKG_VERSION")
426        ));
427        c.args([
428            "--no-bindings",
429            "--tar-autocreate-parents",
430            "--tree=tar=/proc/self/fd/0",
431            "--branch",
432            refname,
433        ]);
434    }
435    let mut c = tokio::process::Command::from(c);
436    c.kill_on_drop(true);
437    let mut r = c.spawn()?;
438    tracing::trace!("Spawned ostree child process");
439    // Safety: We passed piped() for all of these
440    let child_stdin = r.stdin.take().unwrap();
441    let mut child_stdout = r.stdout.take().unwrap();
442    let mut child_stderr = r.stderr.take().unwrap();
443    // Copy the filtered tar stream to child stdin
444    let import_config = TarImportConfig {
445        allow_nonusr: options.allow_nonusr,
446        remap_factory_var: !options.retain_var,
447    };
448    let repo_tmpdir = Dir::reopen_dir(&repo.dfd_borrow())?
449        .open_dir("tmp")
450        .context("Getting repo tmpdir")?;
451    let filtered_result =
452        filter_tar_async(src, media_type, child_stdin, &import_config, repo_tmpdir);
453    let output_copier = async move {
454        // Gather stdout/stderr to buffers
455        let mut child_stdout_buf = String::new();
456        let mut child_stderr_buf = String::new();
457        let (_a, _b) = tokio::try_join!(
458            child_stdout.read_to_string(&mut child_stdout_buf),
459            child_stderr.read_to_string(&mut child_stderr_buf)
460        )?;
461        Ok::<_, anyhow::Error>((child_stdout_buf, child_stderr_buf))
462    };
463
464    // We must convert the child exit status here to an error to
465    // ensure we break out of the try_join! below.
466    let status = async move {
467        let status = r.wait().await?;
468        if !status.success() {
469            return Err(anyhow!("Failed to commit tar: {:?}", status));
470        }
471        anyhow::Ok(())
472    };
473    tracing::debug!("Waiting on child process");
474    let (filtered_result, child_stdout) =
475        match tokio::try_join!(status, filtered_result).context("Processing tar") {
476            Ok(((), filtered_result)) => {
477                let (child_stdout, _) = output_copier.await.context("Copying child output")?;
478                (filtered_result, child_stdout)
479            }
480            Err(e) => {
481                if let Ok((_, child_stderr)) = output_copier.await {
482                    // Avoid trailing newline
483                    let child_stderr = child_stderr.trim();
484                    Err(e.context(child_stderr.to_string()))?
485                } else {
486                    Err(e)?
487                }
488            }
489        };
490    drop(sepolicy);
491
492    tracing::trace!("tar written successfully");
493    // TODO: trim string in place
494    let s = child_stdout.trim();
495    Ok(WriteTarResult {
496        commit: s.to_string(),
497        filtered: filtered_result,
498    })
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use std::io::Cursor;
505
506    #[test]
507    fn test_remap_etc() {
508        // These shouldn't change. Test etcc to verify we're not doing string matching.
509        let unchanged = ["", "foo", "/etcc/foo", "../etc/baz"];
510        for x in unchanged {
511            similar_asserts::assert_eq!(x, remap_etc_path(x.into()).as_str());
512        }
513        // Verify all 3 forms of "./etc", "/etc" and "etc", and also test usage of
514        // ".."" (should be unchanged) and "//" (will be normalized).
515        for (p, expected) in [
516            ("/etc/foo/../bar/baz", "/usr/etc/foo/../bar/baz"),
517            ("etc/foo//bar", "usr/etc/foo/bar"),
518            ("./etc/foo", "./usr/etc/foo"),
519            ("etc", "usr/etc"),
520        ] {
521            similar_asserts::assert_eq!(remap_etc_path(p.into()).as_str(), expected);
522        }
523    }
524
525    #[test]
526    fn test_normalize_path() {
527        let imp_default = &TarImportConfig {
528            allow_nonusr: false,
529            remap_factory_var: true,
530        };
531        let allow_nonusr = &TarImportConfig {
532            allow_nonusr: true,
533            remap_factory_var: true,
534        };
535        let composefs_and_new_ostree = &TarImportConfig {
536            allow_nonusr: true,
537            remap_factory_var: false,
538        };
539        let valid_all = &[
540            ("/usr/bin/blah", "./usr/bin/blah"),
541            ("usr/bin/blah", "./usr/bin/blah"),
542            ("usr///share/.//blah", "./usr/share/blah"),
543            ("var/lib/blah", "./usr/share/factory/var/lib/blah"),
544            ("./var/lib/blah", "./usr/share/factory/var/lib/blah"),
545            ("dev", "./dev"),
546            ("/proc", "./proc"),
547            ("./", "."),
548        ];
549        let valid_nonusr = &[("boot", "./boot"), ("opt/puppet/blah", "./opt/puppet/blah")];
550        for &(k, v) in valid_all {
551            let r = normalize_validate_path(k.into(), imp_default).unwrap();
552            let r2 = normalize_validate_path(k.into(), allow_nonusr).unwrap();
553            assert_eq!(r, r2);
554            match r {
555                NormalizedPathResult::Normal(r) => assert_eq!(r, v),
556                NormalizedPathResult::Filtered(o) => panic!("Should not have filtered {o}"),
557            }
558        }
559        for &(k, v) in valid_nonusr {
560            let strict = normalize_validate_path(k.into(), imp_default).unwrap();
561            assert!(
562                matches!(strict, NormalizedPathResult::Filtered(_)),
563                "Incorrect filter for {k}"
564            );
565            let nonusr = normalize_validate_path(k.into(), allow_nonusr).unwrap();
566            match nonusr {
567                NormalizedPathResult::Normal(r) => assert_eq!(r, v),
568                NormalizedPathResult::Filtered(o) => panic!("Should not have filtered {o}"),
569            }
570        }
571        let filtered = &["/run/blah", "/sys/foo", "/dev/somedev"];
572        for &k in filtered {
573            match normalize_validate_path(k.into(), imp_default).unwrap() {
574                NormalizedPathResult::Filtered(_) => {}
575                NormalizedPathResult::Normal(_) => {
576                    panic!("{} should be filtered", k)
577                }
578            }
579        }
580        let errs = &["usr/foo/../../bar"];
581        for &k in errs {
582            assert!(normalize_validate_path(k.into(), allow_nonusr).is_err());
583            assert!(normalize_validate_path(k.into(), imp_default).is_err());
584        }
585        assert!(matches!(
586            normalize_validate_path("var/lib/foo".into(), composefs_and_new_ostree).unwrap(),
587            NormalizedPathResult::Normal(_)
588        ));
589    }
590
591    #[tokio::test]
592    async fn tar_filter() -> Result<()> {
593        let tempd = tempfile::tempdir()?;
594        let rootfs = &tempd.path().join("rootfs");
595
596        std::fs::create_dir_all(rootfs.join("etc/systemd/system"))?;
597        std::fs::write(rootfs.join("etc/systemd/system/foo.service"), "fooservice")?;
598        std::fs::write(rootfs.join("blah"), "blah")?;
599        let rootfs_tar_path = &tempd.path().join("rootfs.tar");
600        let rootfs_tar = std::fs::File::create(rootfs_tar_path)?;
601        let mut rootfs_tar = tar::Builder::new(rootfs_tar);
602        rootfs_tar.append_dir_all(".", rootfs)?;
603        let _ = rootfs_tar.into_inner()?;
604        let mut dest = Vec::new();
605        let src = tokio::io::BufReader::new(tokio::fs::File::open(rootfs_tar_path).await?);
606        let cap_tmpdir = Dir::open_ambient_dir(&tempd, cap_std::ambient_authority())?;
607        filter_tar_async(
608            src,
609            oci_image::MediaType::ImageLayer,
610            &mut dest,
611            &Default::default(),
612            cap_tmpdir,
613        )
614        .await?;
615        let dest = dest.as_slice();
616        let mut final_tar = tar::Archive::new(Cursor::new(dest));
617        let destdir = &tempd.path().join("destdir");
618        final_tar.unpack(destdir)?;
619        assert!(destdir.join("usr/etc/systemd/system/foo.service").exists());
620        assert!(!destdir.join("blah").exists());
621        Ok(())
622    }
623}