1use 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
18pub const BARE_SPLIT_XATTRS_MODE: &str = "bare-split-xattrs";
20
21const SYSROOT: &str = "sysroot";
23const OSTREEDIR: &str = "sysroot/ostree";
25#[allow(dead_code)]
27const OSTREEREF: &str = "encapsulated";
28
29const TAR_PATH_PREFIX_V0: &str = "./";
35
36const REPO_CONFIG: &str = r#"[core]
39repo_version=1
40mode=bare-split-xattrs
41"#;
42
43const BUF_CAPACITY: usize = 131072;
46
47fn 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
55fn 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 structure_only: bool,
75 wrote_vartmp: bool, 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
109fn 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 fn filter_mode(&self, mode: u32) -> u32 {
163 mode & !libc::S_IFMT
164 }
165
166 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 fn append_default_data(&mut self, path: &Utf8Path, buf: &[u8]) -> Result<()> {
180 tar_append_default_data(self.out, path, buf)
181 }
182
183 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 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 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 for d in 0..=0xFF {
217 let path: Utf8PathBuf = format!("{}/{:02x}", objdir, d).into();
218 self.append_default_dir(&path)?;
219 }
220 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 {
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 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 let metadata = &ostree::DirMetaParsed::from_variant(&metadata_v).unwrap();
261 let rootpath = Utf8Path::new(TAR_PATH_PREFIX_V0);
262
263 self.append_dir(rootpath, metadata)?;
266
267 self.write_repo_structure()?;
269
270 self.append_commit_object()?;
271
272 self.append(ostree::ObjectType::DirMeta, metadata_checksum, &metadata_v)?;
274
275 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 #[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 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 {
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 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 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 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 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 fn append_content_hardlink(
440 &mut self,
441 srcpath: &Utf8Path,
442 mut h: tar::Header,
443 dest: &Utf8Path,
444 ) -> Result<()> {
445 let size = h.size().context("Querying size for hardlink append")?;
447 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 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 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 ostree::DirMetaParsed::from_variant(meta_v).unwrap()
514 };
515 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 fn append_standard_var(&mut self, cancellable: Option<&gio::Cancellable>) -> Result<()> {
536 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
555fn 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#[derive(Debug, PartialEq, Eq, Default)]
572pub struct ExportOptions;
573
574#[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
590fn 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
596fn 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
613pub(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 #[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#[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 writer.structure_only = true;
641 writer.write_commit()?;
642 writer.structure_only = false;
643 write_chunk(writer, remainder.content)
644}
645
646#[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 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); crate::tar::write::copy_entry(commit_ent, dest, None)?;
681
682 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 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 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
710pub 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}