ostree_ext/tar/
export.rs

1//! APIs for creating container images from OSTree commits
2
3use crate::chunking;
4use crate::objgv::*;
5use anyhow::{anyhow, ensure, Context, Result};
6use camino::{Utf8Path, Utf8PathBuf};
7use fn_error_context::context;
8use gio::glib;
9use gio::prelude::*;
10use gvariant::aligned_bytes::TryAsAligned;
11use gvariant::{Marker, Structure};
12use ostree::gio;
13use std::borrow::Borrow;
14use std::borrow::Cow;
15use std::collections::HashSet;
16use std::io::BufReader;
17
18/// The repository mode generated by a tar export stream.
19pub const BARE_SPLIT_XATTRS_MODE: &str = "bare-split-xattrs";
20
21// This is both special in the tar stream *and* it's in the ostree commit.
22const SYSROOT: &str = "sysroot";
23// This way the default ostree -> sysroot/ostree symlink works.
24const OSTREEDIR: &str = "sysroot/ostree";
25// The ref added (under ostree/) in the exported OSTree repo pointing at the commit.
26#[allow(dead_code)]
27const OSTREEREF: &str = "encapsulated";
28
29/// In v0 format, we use this relative path prefix.  I think I chose this by looking
30/// at the current Fedora base image tar stream.  However, several others don't do
31/// this and have paths be relative by simply omitting `./`, i.e. the tar stream
32/// contains `usr/bin/bash` and not `./usr/bin/bash`.  The former looks cleaner
33/// to me, so in v1 we drop it.
34const TAR_PATH_PREFIX_V0: &str = "./";
35
36/// The base repository configuration that identifies this is a tar export.
37// See https://github.com/ostreedev/ostree/issues/2499
38const REPO_CONFIG: &str = r#"[core]
39repo_version=1
40mode=bare-split-xattrs
41"#;
42
43/// A decently large buffer, as used by e.g. coreutils `cat`.
44/// System calls are expensive.
45const BUF_CAPACITY: usize = 131072;
46
47/// Convert /usr/etc back to /etc
48fn map_path(p: &Utf8Path) -> std::borrow::Cow<Utf8Path> {
49    match p.strip_prefix("./usr/etc") {
50        Ok(r) => Cow::Owned(Utf8Path::new("./etc").join(r)),
51        _ => Cow::Borrowed(p),
52    }
53}
54
55/// Convert usr/etc back to etc for the tar stream.
56fn map_path_v1(p: &Utf8Path) -> &Utf8Path {
57    debug_assert!(!p.starts_with("/") && !p.starts_with("."));
58    if p.starts_with("usr/etc") {
59        p.strip_prefix("usr/").unwrap()
60    } else {
61        p
62    }
63}
64
65struct OstreeTarWriter<'a, W: std::io::Write> {
66    repo: &'a ostree::Repo,
67    commit_checksum: &'a str,
68    commit_object: glib::Variant,
69    out: &'a mut tar::Builder<W>,
70    #[allow(dead_code)]
71    options: ExportOptions,
72    wrote_initdirs: bool,
73    /// True if we're only writing directories
74    structure_only: bool,
75    wrote_vartmp: bool, // Set if the ostree commit contains /var/tmp
76    wrote_dirtree: HashSet<String>,
77    wrote_dirmeta: HashSet<String>,
78    wrote_content: HashSet<String>,
79    wrote_xattrs: HashSet<String>,
80}
81
82pub(crate) fn object_path(objtype: ostree::ObjectType, checksum: &str) -> Utf8PathBuf {
83    let suffix = match objtype {
84        ostree::ObjectType::Commit => "commit",
85        ostree::ObjectType::CommitMeta => "commitmeta",
86        ostree::ObjectType::DirTree => "dirtree",
87        ostree::ObjectType::DirMeta => "dirmeta",
88        ostree::ObjectType::File => "file",
89        o => panic!("Unexpected object type: {:?}", o),
90    };
91    let (first, rest) = checksum.split_at(2);
92    format!("{}/repo/objects/{}/{}.{}", OSTREEDIR, first, rest, suffix).into()
93}
94
95fn v1_xattrs_object_path(checksum: &str) -> Utf8PathBuf {
96    let (first, rest) = checksum.split_at(2);
97    format!("{}/repo/objects/{}/{}.file-xattrs", OSTREEDIR, first, rest).into()
98}
99
100fn v1_xattrs_link_object_path(checksum: &str) -> Utf8PathBuf {
101    let (first, rest) = checksum.split_at(2);
102    format!(
103        "{}/repo/objects/{}/{}.file-xattrs-link",
104        OSTREEDIR, first, rest
105    )
106    .into()
107}
108
109/// Check for "denormal" symlinks which contain "//"
110// See https://github.com/fedora-sysv/chkconfig/pull/67
111// [root@cosa-devsh ~]# rpm -qf /usr/lib/systemd/systemd-sysv-install
112// chkconfig-1.13-2.el8.x86_64
113// [root@cosa-devsh ~]# ll /usr/lib/systemd/systemd-sysv-install
114// lrwxrwxrwx. 2 root root 24 Nov 29 18:08 /usr/lib/systemd/systemd-sysv-install -> ../../..//sbin/chkconfig
115// [root@cosa-devsh ~]#
116fn symlink_is_denormal(target: &str) -> bool {
117    target.contains("//")
118}
119
120pub(crate) fn tar_append_default_data(
121    out: &mut tar::Builder<impl std::io::Write>,
122    path: &Utf8Path,
123    buf: &[u8],
124) -> Result<()> {
125    let mut h = tar::Header::new_gnu();
126    h.set_entry_type(tar::EntryType::Regular);
127    h.set_uid(0);
128    h.set_gid(0);
129    h.set_mode(0o644);
130    h.set_size(buf.len() as u64);
131    out.append_data(&mut h, path, buf).map_err(Into::into)
132}
133
134impl<'a, W: std::io::Write> OstreeTarWriter<'a, W> {
135    fn new(
136        repo: &'a ostree::Repo,
137        commit_checksum: &'a str,
138        out: &'a mut tar::Builder<W>,
139        options: ExportOptions,
140    ) -> Result<Self> {
141        let commit_object = repo.load_commit(commit_checksum)?.0;
142        let r = Self {
143            repo,
144            commit_checksum,
145            commit_object,
146            out,
147            options,
148            wrote_initdirs: false,
149            structure_only: false,
150            wrote_vartmp: false,
151            wrote_dirmeta: HashSet::new(),
152            wrote_dirtree: HashSet::new(),
153            wrote_content: HashSet::new(),
154            wrote_xattrs: HashSet::new(),
155        };
156        Ok(r)
157    }
158
159    /// Convert the ostree mode to tar mode.
160    /// The ostree mode bits include the format, tar does not.
161    /// Historically in format version 0 we injected them, so we need to keep doing so.
162    fn filter_mode(&self, mode: u32) -> u32 {
163        mode & !libc::S_IFMT
164    }
165
166    /// Add a directory entry with default permissions (root/root 0755)
167    fn append_default_dir(&mut self, path: &Utf8Path) -> Result<()> {
168        let mut h = tar::Header::new_gnu();
169        h.set_entry_type(tar::EntryType::Directory);
170        h.set_uid(0);
171        h.set_gid(0);
172        h.set_mode(0o755);
173        h.set_size(0);
174        self.out.append_data(&mut h, path, &mut std::io::empty())?;
175        Ok(())
176    }
177
178    /// Add a regular file entry with default permissions (root/root 0644)
179    fn append_default_data(&mut self, path: &Utf8Path, buf: &[u8]) -> Result<()> {
180        tar_append_default_data(self.out, path, buf)
181    }
182
183    /// Add an hardlink entry with default permissions (root/root 0644)
184    fn append_default_hardlink(&mut self, path: &Utf8Path, link_target: &Utf8Path) -> Result<()> {
185        let mut h = tar::Header::new_gnu();
186        h.set_entry_type(tar::EntryType::Link);
187        h.set_uid(0);
188        h.set_gid(0);
189        h.set_mode(0o644);
190        h.set_size(0);
191        self.out.append_link(&mut h, path, link_target)?;
192        Ok(())
193    }
194
195    /// Write the initial /sysroot/ostree/repo structure.
196    fn write_repo_structure(&mut self) -> Result<()> {
197        if self.wrote_initdirs {
198            return Ok(());
199        }
200
201        let objdir: Utf8PathBuf = format!("{}/repo/objects", OSTREEDIR).into();
202        // Add all parent directories
203        let parent_dirs = {
204            let mut parts: Vec<_> = objdir.ancestors().collect();
205            parts.reverse();
206            parts
207        };
208        for path in parent_dirs {
209            match path.as_str() {
210                "/" | "" => continue,
211                _ => {}
212            }
213            self.append_default_dir(path)?;
214        }
215        // Object subdirectories
216        for d in 0..=0xFF {
217            let path: Utf8PathBuf = format!("{}/{:02x}", objdir, d).into();
218            self.append_default_dir(&path)?;
219        }
220        // Standard repo subdirectories.
221        let subdirs = [
222            "extensions",
223            "refs",
224            "refs/heads",
225            "refs/mirrors",
226            "refs/remotes",
227            "state",
228            "tmp",
229            "tmp/cache",
230        ];
231        for d in subdirs {
232            let path: Utf8PathBuf = format!("{}/repo/{}", OSTREEDIR, d).into();
233            self.append_default_dir(&path)?;
234        }
235
236        // Repository configuration file.
237        {
238            let path = format!("{}/repo/config", OSTREEDIR);
239            self.append_default_data(Utf8Path::new(&path), REPO_CONFIG.as_bytes())?;
240        }
241
242        self.wrote_initdirs = true;
243        Ok(())
244    }
245
246    /// Recursively serialize a commit object to the target tar stream.
247    fn write_commit(&mut self) -> Result<()> {
248        let cancellable = gio::Cancellable::NONE;
249
250        let commit_bytes = self.commit_object.data_as_bytes();
251        let commit_bytes = commit_bytes.try_as_aligned()?;
252        let commit = gv_commit!().cast(commit_bytes);
253        let commit = commit.to_tuple();
254        let contents = hex::encode(commit.6);
255        let metadata_checksum = &hex::encode(commit.7);
256        let metadata_v = self
257            .repo
258            .load_variant(ostree::ObjectType::DirMeta, metadata_checksum)?;
259        // Safety: We passed the correct variant type just above
260        let metadata = &ostree::DirMetaParsed::from_variant(&metadata_v).unwrap();
261        let rootpath = Utf8Path::new(TAR_PATH_PREFIX_V0);
262
263        // We need to write the root directory, before we write any objects.  This should be the very
264        // first thing.
265        self.append_dir(rootpath, metadata)?;
266
267        // Now, we create sysroot/ and everything under it
268        self.write_repo_structure()?;
269
270        self.append_commit_object()?;
271
272        // The ostree dirmeta object for the root.
273        self.append(ostree::ObjectType::DirMeta, metadata_checksum, &metadata_v)?;
274
275        // Recurse and write everything else.
276        self.append_dirtree(
277            Utf8Path::new(TAR_PATH_PREFIX_V0),
278            contents,
279            true,
280            cancellable,
281        )?;
282
283        self.append_standard_var(cancellable)?;
284
285        Ok(())
286    }
287
288    fn append_commit_object(&mut self) -> Result<()> {
289        self.append(
290            ostree::ObjectType::Commit,
291            self.commit_checksum,
292            &self.commit_object.clone(),
293        )?;
294        if let Some(commitmeta) = self
295            .repo
296            .read_commit_detached_metadata(self.commit_checksum, gio::Cancellable::NONE)?
297        {
298            self.append(
299                ostree::ObjectType::CommitMeta,
300                self.commit_checksum,
301                &commitmeta,
302            )?;
303        }
304        Ok(())
305    }
306
307    fn append(
308        &mut self,
309        objtype: ostree::ObjectType,
310        checksum: &str,
311        v: &glib::Variant,
312    ) -> Result<()> {
313        let set = match objtype {
314            ostree::ObjectType::Commit | ostree::ObjectType::CommitMeta => None,
315            ostree::ObjectType::DirTree => Some(&mut self.wrote_dirtree),
316            ostree::ObjectType::DirMeta => Some(&mut self.wrote_dirmeta),
317            o => panic!("Unexpected object type: {:?}", o),
318        };
319        if let Some(set) = set {
320            if set.contains(checksum) {
321                return Ok(());
322            }
323            let inserted = set.insert(checksum.to_string());
324            debug_assert!(inserted);
325        }
326
327        let data = v.data_as_bytes();
328        let data = data.as_ref();
329        self.append_default_data(&object_path(objtype, checksum), data)
330            .with_context(|| format!("Writing object {checksum}"))?;
331        Ok(())
332    }
333
334    /// Export xattrs to the tar stream, return whether content was written.
335    #[context("Writing xattrs")]
336    fn append_xattrs(&mut self, checksum: &str, xattrs: &glib::Variant) -> Result<bool> {
337        let xattrs_data = xattrs.data_as_bytes();
338        let xattrs_data = xattrs_data.as_ref();
339
340        let xattrs_checksum = {
341            let digest = openssl::hash::hash(openssl::hash::MessageDigest::sha256(), xattrs_data)?;
342            hex::encode(digest)
343        };
344
345        let path = v1_xattrs_object_path(&xattrs_checksum);
346        // Write xattrs content into a separate `.file-xattrs` object.
347        if !self.wrote_xattrs.contains(&xattrs_checksum) {
348            let inserted = self.wrote_xattrs.insert(xattrs_checksum);
349            debug_assert!(inserted);
350            self.append_default_data(&path, xattrs_data)?;
351        }
352        // Write a `.file-xattrs-link` which links the file object to
353        // the corresponding detached xattrs.
354        {
355            let link_obj_path = v1_xattrs_link_object_path(checksum);
356            self.append_default_hardlink(&link_obj_path, &path)?;
357        }
358
359        Ok(true)
360    }
361
362    /// Write a content object, returning the path/header that should be used
363    /// as a hard link to it in the target path. This matches how ostree checkouts work.
364    fn append_content(&mut self, checksum: &str) -> Result<(Utf8PathBuf, tar::Header)> {
365        let path = object_path(ostree::ObjectType::File, checksum);
366
367        let (instream, meta, xattrs) = self.repo.load_file(checksum, gio::Cancellable::NONE)?;
368
369        let mut h = tar::Header::new_gnu();
370        h.set_uid(meta.attribute_uint32("unix::uid") as u64);
371        h.set_gid(meta.attribute_uint32("unix::gid") as u64);
372        let mode = meta.attribute_uint32("unix::mode");
373        h.set_mode(self.filter_mode(mode));
374        if instream.is_some() {
375            h.set_size(meta.size() as u64);
376        }
377        if !self.wrote_content.contains(checksum) {
378            let inserted = self.wrote_content.insert(checksum.to_string());
379            debug_assert!(inserted);
380
381            // The xattrs objects need to be exported before the regular object they
382            // refer to. Otherwise the importing logic won't have the xattrs available
383            // when importing file content.
384            self.append_xattrs(checksum, &xattrs)?;
385
386            if let Some(instream) = instream {
387                ensure!(meta.file_type() == gio::FileType::Regular);
388
389                h.set_entry_type(tar::EntryType::Regular);
390                h.set_size(meta.size() as u64);
391                let mut instream = BufReader::with_capacity(BUF_CAPACITY, instream.into_read());
392                self.out
393                    .append_data(&mut h, &path, &mut instream)
394                    .with_context(|| format!("Writing regfile {}", checksum))?;
395            } else {
396                ensure!(meta.file_type() == gio::FileType::SymbolicLink);
397
398                let target = meta
399                    .symlink_target()
400                    .ok_or_else(|| anyhow!("Missing symlink target"))?;
401                let target = target
402                    .to_str()
403                    .ok_or_else(|| anyhow!("Invalid UTF-8 symlink target: {target:?}"))?;
404                let context = || format!("Writing content symlink: {}", checksum);
405                h.set_entry_type(tar::EntryType::Symlink);
406                h.set_size(0);
407                // Handle //chkconfig, see above
408                if symlink_is_denormal(target) {
409                    h.set_link_name_literal(target).with_context(context)?;
410                    self.out
411                        .append_data(&mut h, &path, &mut std::io::empty())
412                        .with_context(context)?;
413                } else {
414                    self.out
415                        .append_link(&mut h, &path, target)
416                        .with_context(context)?;
417                }
418            }
419        }
420
421        Ok((path, h))
422    }
423
424    /// Write a directory using the provided metadata.
425    fn append_dir(&mut self, dirpath: &Utf8Path, meta: &ostree::DirMetaParsed) -> Result<()> {
426        let mut header = tar::Header::new_gnu();
427        header.set_entry_type(tar::EntryType::Directory);
428        header.set_size(0);
429        header.set_uid(meta.uid as u64);
430        header.set_gid(meta.gid as u64);
431        header.set_mode(self.filter_mode(meta.mode));
432        self.out
433            .append_data(&mut header, dirpath, std::io::empty())?;
434        Ok(())
435    }
436
437    /// Given a source object (in e.g. ostree/repo/objects/...), write a hardlink to it
438    /// in its expected target path (e.g. `usr/bin/bash`).
439    fn append_content_hardlink(
440        &mut self,
441        srcpath: &Utf8Path,
442        mut h: tar::Header,
443        dest: &Utf8Path,
444    ) -> Result<()> {
445        // Query the original size first
446        let size = h.size().context("Querying size for hardlink append")?;
447        // Don't create hardlinks to zero-sized files, it's much more likely
448        // to result in generated tar streams from container builds resulting
449        // in a modified linked-to file in /sysroot, which we don't currently handle.
450        // And in the case where the input is *not* zero sized, we still output
451        // a hardlink of size zero, as this is what is normal.
452        h.set_size(0);
453        if h.entry_type() == tar::EntryType::Regular && size == 0 {
454            self.out.append_data(&mut h, dest, &mut std::io::empty())?;
455        } else {
456            h.set_entry_type(tar::EntryType::Link);
457            h.set_link_name(srcpath)?;
458            self.out.append_data(&mut h, dest, &mut std::io::empty())?;
459        }
460        Ok(())
461    }
462
463    /// Write a dirtree object.
464    fn append_dirtree<C: IsA<gio::Cancellable>>(
465        &mut self,
466        dirpath: &Utf8Path,
467        checksum: String,
468        is_root: bool,
469        cancellable: Option<&C>,
470    ) -> Result<()> {
471        let v = &self
472            .repo
473            .load_variant(ostree::ObjectType::DirTree, &checksum)?;
474        self.append(ostree::ObjectType::DirTree, &checksum, v)?;
475        drop(checksum);
476        let v = v.data_as_bytes();
477        let v = v.try_as_aligned()?;
478        let v = gv_dirtree!().cast(v);
479        let (files, dirs) = v.to_tuple();
480
481        if let Some(c) = cancellable {
482            c.set_error_if_cancelled()?;
483        }
484
485        if !self.structure_only {
486            for file in files {
487                let (name, csum) = file.to_tuple();
488                let name = name.to_str();
489                let checksum = &hex::encode(csum);
490                let (objpath, h) = self.append_content(checksum)?;
491                let subpath = &dirpath.join(name);
492                let subpath = map_path(subpath);
493                self.append_content_hardlink(&objpath, h, &subpath)?;
494            }
495        }
496
497        // Record if the ostree commit includes /var/tmp; if so we don't need to synthesize
498        // it in `append_standard_var()`.
499        if dirpath == "var/tmp" {
500            self.wrote_vartmp = true;
501        }
502
503        for item in dirs {
504            let (name, contents_csum, meta_csum) = item.to_tuple();
505            let name = name.to_str();
506            let metadata = {
507                let meta_csum = &hex::encode(meta_csum);
508                let meta_v = &self
509                    .repo
510                    .load_variant(ostree::ObjectType::DirMeta, meta_csum)?;
511                self.append(ostree::ObjectType::DirMeta, meta_csum, meta_v)?;
512                // Safety: We passed the correct variant type just above
513                ostree::DirMetaParsed::from_variant(meta_v).unwrap()
514            };
515            // Special hack because tar stream for containers can't have duplicates.
516            if is_root && name == SYSROOT {
517                continue;
518            }
519            let dirtree_csum = hex::encode(contents_csum);
520            let subpath = &dirpath.join(name);
521            let subpath = map_path(subpath);
522            self.append_dir(&subpath, &metadata)?;
523            self.append_dirtree(&subpath, dirtree_csum, false, cancellable)?;
524        }
525
526        Ok(())
527    }
528
529    /// Generate e.g. `/var/tmp`.
530    ///
531    /// In the OSTree model we expect `/var` to start out empty, and be populated via
532    /// e.g. `systemd-tmpfiles`.  But, systemd doesn't run in Docker-style containers by default.
533    ///
534    /// So, this function creates a few critical directories in `/var` by default.
535    fn append_standard_var(&mut self, cancellable: Option<&gio::Cancellable>) -> Result<()> {
536        // If the commit included /var/tmp, then it's already in the tar stream.
537        if self.wrote_vartmp {
538            return Ok(());
539        }
540        if let Some(c) = cancellable {
541            c.set_error_if_cancelled()?;
542        }
543        let mut header = tar::Header::new_gnu();
544        header.set_entry_type(tar::EntryType::Directory);
545        header.set_size(0);
546        header.set_uid(0);
547        header.set_gid(0);
548        header.set_mode(self.filter_mode(libc::S_IFDIR | 0o1777));
549        self.out
550            .append_data(&mut header, "var/tmp", std::io::empty())?;
551        Ok(())
552    }
553}
554
555/// Recursively walk an OSTree commit and generate data into a `[tar::Builder]`
556/// which contains all of the metadata objects, as well as a hardlinked
557/// stream that looks like a checkout.  Extended attributes are stored specially out
558/// of band of tar so that they can be reliably retrieved.
559fn impl_export<W: std::io::Write>(
560    repo: &ostree::Repo,
561    commit_checksum: &str,
562    out: &mut tar::Builder<W>,
563    options: ExportOptions,
564) -> Result<()> {
565    let writer = &mut OstreeTarWriter::new(repo, commit_checksum, out, options)?;
566    writer.write_commit()?;
567    Ok(())
568}
569
570/// Configuration for tar export.
571#[derive(Debug, PartialEq, Eq, Default)]
572pub struct ExportOptions;
573
574/// Export an ostree commit to an (uncompressed) tar archive stream.
575#[context("Exporting commit")]
576pub fn export_commit(
577    repo: &ostree::Repo,
578    rev: &str,
579    out: impl std::io::Write,
580    options: Option<ExportOptions>,
581) -> Result<()> {
582    let commit = repo.require_rev(rev)?;
583    let mut tar = tar::Builder::new(out);
584    let options = options.unwrap_or_default();
585    impl_export(repo, commit.as_str(), &mut tar, options)?;
586    tar.finish()?;
587    Ok(())
588}
589
590/// Chunked (or version 1) tar streams don't have a leading `./`.
591fn path_for_tar_v1(p: &Utf8Path) -> &Utf8Path {
592    debug_assert!(!p.starts_with("."));
593    map_path_v1(p.strip_prefix("/").unwrap_or(p))
594}
595
596/// Implementation of chunk writing, assumes that the preliminary structure
597/// has been written to the tar stream.
598fn write_chunk<W: std::io::Write>(
599    writer: &mut OstreeTarWriter<W>,
600    chunk: chunking::ChunkMapping,
601) -> Result<()> {
602    for (checksum, (_size, paths)) in chunk.into_iter() {
603        let (objpath, h) = writer.append_content(checksum.borrow())?;
604        for path in paths.iter() {
605            let path = path_for_tar_v1(path);
606            let h = h.clone();
607            writer.append_content_hardlink(&objpath, h, path)?;
608        }
609    }
610    Ok(())
611}
612
613/// Output a chunk to a tar stream.
614pub(crate) fn export_chunk<W: std::io::Write>(
615    repo: &ostree::Repo,
616    commit: &str,
617    chunk: chunking::ChunkMapping,
618    out: &mut tar::Builder<W>,
619) -> Result<()> {
620    // For chunking, we default to format version 1
621    #[allow(clippy::needless_update)]
622    let opts = ExportOptions;
623    let writer = &mut OstreeTarWriter::new(repo, commit, out, opts)?;
624    writer.write_repo_structure()?;
625    write_chunk(writer, chunk)
626}
627
628/// Output the last chunk in a chunking.
629#[context("Exporting final chunk")]
630pub(crate) fn export_final_chunk<W: std::io::Write>(
631    repo: &ostree::Repo,
632    commit_checksum: &str,
633    remainder: chunking::Chunk,
634    out: &mut tar::Builder<W>,
635) -> Result<()> {
636    let options = ExportOptions;
637    let writer = &mut OstreeTarWriter::new(repo, commit_checksum, out, options)?;
638    // For the final chunk, output the commit object, plus all ostree metadata objects along with
639    // the containing directories.
640    writer.structure_only = true;
641    writer.write_commit()?;
642    writer.structure_only = false;
643    write_chunk(writer, remainder.content)
644}
645
646/// Process an exported tar stream, and update the detached metadata.
647#[allow(clippy::while_let_on_iterator)]
648#[context("Replacing detached metadata")]
649pub(crate) fn reinject_detached_metadata<C: IsA<gio::Cancellable>>(
650    src: &mut tar::Archive<impl std::io::Read>,
651    dest: &mut tar::Builder<impl std::io::Write>,
652    detached_buf: Option<&[u8]>,
653    cancellable: Option<&C>,
654) -> Result<()> {
655    let mut entries = src.entries()?;
656    let mut commit_ent = None;
657    // Loop through the tar stream until we find the commit object; copy all prior entries
658    // such as the baseline directory structure.
659    while let Some(entry) = entries.next() {
660        if let Some(c) = cancellable {
661            c.set_error_if_cancelled()?;
662        }
663        let entry = entry?;
664        let header = entry.header();
665        let path = entry.path()?;
666        let path: &Utf8Path = (&*path).try_into()?;
667        if !(header.entry_type() == tar::EntryType::Regular && path.as_str().ends_with(".commit")) {
668            crate::tar::write::copy_entry(entry, dest, None)?;
669        } else {
670            commit_ent = Some(entry);
671            break;
672        }
673    }
674    let commit_ent = commit_ent.ok_or_else(|| anyhow!("Missing commit object"))?;
675    let commit_path = commit_ent.path()?;
676    let commit_path = Utf8Path::from_path(&commit_path)
677        .ok_or_else(|| anyhow!("Invalid non-utf8 path {:?}", commit_path))?;
678    let (checksum, objtype) = crate::tar::import::Importer::parse_metadata_entry(commit_path)?;
679    assert_eq!(objtype, ostree::ObjectType::Commit); // Should have been verified above
680    crate::tar::write::copy_entry(commit_ent, dest, None)?;
681
682    // If provided, inject our new detached metadata object
683    if let Some(detached_buf) = detached_buf {
684        let detached_path = object_path(ostree::ObjectType::CommitMeta, &checksum);
685        tar_append_default_data(dest, &detached_path, detached_buf)?;
686    }
687
688    // If the next entry is detached metadata, then drop it since we wrote a new one
689    let next_ent = entries
690        .next()
691        .ok_or_else(|| anyhow!("Expected metadata object after commit"))??;
692    let next_ent_path = next_ent.path()?;
693    let next_ent_path: &Utf8Path = (&*next_ent_path).try_into()?;
694    let objtype = crate::tar::import::Importer::parse_metadata_entry(next_ent_path)?.1;
695    if objtype != ostree::ObjectType::CommitMeta {
696        crate::tar::write::copy_entry(next_ent, dest, None)?;
697    }
698
699    // Finally, copy all remaining entries.
700    while let Some(entry) = entries.next() {
701        if let Some(c) = cancellable {
702            c.set_error_if_cancelled()?;
703        }
704        crate::tar::write::copy_entry(entry?, dest, None)?;
705    }
706
707    Ok(())
708}
709
710/// Replace the detached metadata in an tar stream which is an export of an OSTree commit.
711pub fn update_detached_metadata<D: std::io::Write, C: IsA<gio::Cancellable>>(
712    src: impl std::io::Read,
713    dest: D,
714    detached_buf: Option<&[u8]>,
715    cancellable: Option<&C>,
716) -> Result<D> {
717    let mut src = tar::Archive::new(src);
718    let mut dest = tar::Builder::new(dest);
719    reinject_detached_metadata(&mut src, &mut dest, detached_buf, cancellable)?;
720    dest.into_inner().map_err(Into::into)
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn test_map_path() {
729        assert_eq!(map_path("/".into()), Utf8Path::new("/"));
730        assert_eq!(
731            map_path("./usr/etc/blah".into()),
732            Utf8Path::new("./etc/blah")
733        );
734        for unchanged in ["boot", "usr/bin", "usr/lib/foo"].iter().map(Utf8Path::new) {
735            assert_eq!(unchanged, map_path_v1(unchanged));
736        }
737
738        assert_eq!(Utf8Path::new("etc"), map_path_v1(Utf8Path::new("usr/etc")));
739        assert_eq!(
740            Utf8Path::new("etc/foo"),
741            map_path_v1(Utf8Path::new("usr/etc/foo"))
742        );
743    }
744
745    #[test]
746    fn test_denormal_symlink() {
747        let normal = ["/", "/usr", "../usr/bin/blah"];
748        let denormal = ["../../usr/sbin//chkconfig", "foo//bar/baz"];
749        for path in normal {
750            assert!(!symlink_is_denormal(path));
751        }
752        for path in denormal {
753            assert!(symlink_is_denormal(path));
754        }
755    }
756
757    #[test]
758    fn test_v1_xattrs_object_path() {
759        let checksum = "b8627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7";
760        let expected = "sysroot/ostree/repo/objects/b8/627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file-xattrs";
761        let output = v1_xattrs_object_path(checksum);
762        assert_eq!(&output, expected);
763    }
764
765    #[test]
766    fn test_v1_xattrs_link_object_path() {
767        let checksum = "b8627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7";
768        let expected = "sysroot/ostree/repo/objects/b8/627e3ef0f255a322d2bd9610cfaaacc8f122b7f8d17c0e7e3caafa160f9fc7.file-xattrs-link";
769        let output = v1_xattrs_link_object_path(checksum);
770        assert_eq!(&output, expected);
771    }
772}