diff --git a/crates/composefs-ctl/src/lib.rs b/crates/composefs-ctl/src/lib.rs index e015621a..9ab45d1e 100644 --- a/crates/composefs-ctl/src/lib.rs +++ b/crates/composefs-ctl/src/lib.rs @@ -597,6 +597,22 @@ enum OstreeCommand { #[clap(long, default_value = "")] subject: String, }, + /// Export an ostree commit to a local ostree repository + /// + /// Writes all objects (files, dirtrees, dirmetas, commit) to the + /// destination repo. File content is reflinked when possible. + /// Only bare, bare-user, and bare-user-only repos are supported. + Export { + /// Ostree ref name or commit ID to export + #[arg(add = ArgValueCompleter::new(complete_ostree_refs))] + source: String, + /// Path to the destination ostree repository + #[arg(value_hint = clap::ValueHint::DirPath)] + ostree_repo_path: PathBuf, + /// Ref name to set in the destination repo + #[clap(long)] + reference: Option, + }, /// List all ostree commits in the repository #[clap(name = "images")] ListCommits, @@ -1889,6 +1905,19 @@ where println!("tagged {ref_name}"); } } + OstreeCommand::Export { + ref source, + ref ostree_repo_path, + ref reference, + } => { + let dest = composefs_ostree::LocalRepo::open_path(&repo, CWD, ostree_repo_path)?; + let commit_id = + composefs_ostree::export_commit(&repo, source, &dest, reference.as_deref())?; + println!("commit {commit_id}"); + if let Some(ref_name) = reference { + println!("tagged {ref_name}"); + } + } OstreeCommand::ListCommits => { let commits = composefs_ostree::list_commits(&repo)?; if commits.is_empty() { diff --git a/crates/composefs-integration-tests/src/tests/ostree.rs b/crates/composefs-integration-tests/src/tests/ostree.rs index 6acb71ef..16d6de06 100644 --- a/crates/composefs-integration-tests/src/tests/ostree.rs +++ b/crates/composefs-integration-tests/src/tests/ostree.rs @@ -26,6 +26,14 @@ use composefs_integration_tests::create_test_repository; /// - Symlinks with changed targets /// - Nested directory changes (new dirtree metadata) fn create_ostree_test_content(parent: &Path, version: u32) -> Result { + create_ostree_test_content_opts(parent, version, false) +} + +fn create_ostree_test_content_opts( + parent: &Path, + version: u32, + bare_user_only: bool, +) -> Result { let root = parent.join("content"); if root.exists() { std::fs::remove_dir_all(&root)?; @@ -60,12 +68,14 @@ fn create_ostree_test_content(parent: &Path, version: u32) -> Result Result<()> { } integration_test!(test_ostree_commit_roundtrip); +fn test_ostree_export() -> Result<()> { + let sh = Shell::new()?; + let tmpdir = TempDir::new()?; + + let composefs_dir = TempDir::new()?; + let repo = create_test_repository(&composefs_dir)?; + + for mode in &["bare-user", "bare-user-only"] { + let is_buo = *mode == "bare-user-only"; + let content = create_ostree_test_content_opts(tmpdir.path(), 1, is_buo)?; + + let ostree_source = tmpdir.path().join(format!("ostree-source-{mode}")); + init_ostree_repo(&sh, &ostree_source, "archive-z2")?; + let source_str = ostree_source.to_str().unwrap(); + let content_str = content.to_str().unwrap(); + + let original_commit_id = if is_buo { + cmd!( + sh, + "ostree commit --repo={source_str} --branch=test --owner-uid=0 --owner-gid=0 --no-xattrs {content_str}" + ) + .read()? + .trim() + .to_string() + } else { + commit_to_ostree(&sh, &ostree_source, "test", &content)? + }; + + pull_and_get_image_id(&repo, &ostree_source, "test", None)?; + + let dest_path = tmpdir.path().join(format!("ostree-export-{mode}")); + init_ostree_repo(&sh, &dest_path, mode)?; + + let dest = composefs_ostree::LocalRepo::open_path(&repo, rustix::fs::CWD, &dest_path)?; + let exported_id = composefs_ostree::export_commit(&repo, "test", &dest, Some("exported"))?; + + assert_eq!( + original_commit_id, exported_id, + "{mode}: exported commit ID differs from original" + ); + + let dest_str = dest_path.to_str().unwrap(); + cmd!(sh, "ostree --repo={dest_str} fsck").run()?; + + let ref_output = cmd!(sh, "ostree --repo={dest_str} rev-parse exported").read()?; + assert_eq!( + original_commit_id, + ref_output.trim(), + "{mode}: ref 'exported' points to wrong commit" + ); + } + + Ok(()) +} +integration_test!(test_ostree_export); + fn test_ostree_apply_delta_offline() -> Result<()> { let sh = Shell::new()?; let tmpdir = TempDir::new()?; diff --git a/crates/composefs-ostree/Cargo.toml b/crates/composefs-ostree/Cargo.toml index af1783d0..8d49f1ed 100644 --- a/crates/composefs-ostree/Cargo.toml +++ b/crates/composefs-ostree/Cargo.toml @@ -15,11 +15,13 @@ anyhow = { version = "1.0.87", default-features = false } base64 = { version = "0.22", default-features = false, features = ["std"] } bsdiff = { version = "0.2.1", default-features = false } chrono = { version = "0.4", default-features = false, features = ["alloc"] } +cap-std = { version = "4.0.0", default-features = false } composefs = { workspace = true } configparser = { version = "3.1.0", features = [] } flate2 = { version = "1.1.2", default-features = true } gvariant = { version = "0.5.1", default-features = true} hex = { version = "0.4.0", default-features = false, features = ["std"] } +rand = { version = "0.10.0", default-features = false, features = ["thread_rng"] } rustix = { version = "1.0.0", default-features = false, features = ["fs", "mount", "process", "std"] } sha2 = { version = "0.11.0", default-features = false } zerocopy = { version = "0.8.0", default-features = false, features = ["derive", "std"] } diff --git a/crates/composefs-ostree/src/lib.rs b/crates/composefs-ostree/src/lib.rs index 4df1a70f..3a2d84d9 100644 --- a/crates/composefs-ostree/src/lib.rs +++ b/crates/composefs-ostree/src/lib.rs @@ -373,6 +373,111 @@ pub fn commit_filesystem( Ok((verity, hex::encode(commit_id))) } +/// Export an ostree commit from the composefs repository to a local ostree repository. +/// +/// Walks the commit's tree depth-first, writing all objects (files, dirtrees, +/// dirmetas, commit) to the destination repo. File content is reflinked when +/// possible. If `reference` is given, the ref is set in the destination repo. +/// +/// Only bare, bare-user, and bare-user-only destination repos are supported. +pub fn export_commit( + repo: &Repository, + source: &str, + dest: &LocalRepo, + reference: Option<&str>, +) -> Result { + if dest.mode() == ostree::RepoMode::Archive { + bail!("Export to archive-mode repositories is not supported"); + } + + let content_id = resolve_source(repo, source)?; + let reader = CommitReader::::load(repo, &content_id)?; + let commit_id = reader.commit_id(); + + let commit_data = reader + .lookup_data(&commit_id)? + .ok_or_else(|| anyhow::anyhow!("commit object not found in stream"))?; + let commit = ostree::OstreeCommit::from_data(commit_data)?; + + export_dir( + repo, + &reader, + dest, + &commit.root_tree, + &commit.root_metadata, + )?; + + dest.write_metadata_object(&commit_id, ostree::ObjectType::Commit, commit_data)?; + + if let Some(ref_name) = reference { + dest.write_ref(ref_name, &commit_id)?; + } + + rustix::fs::syncfs(dest.dir())?; + + Ok(hex::encode(commit_id)) +} + +fn export_dir( + repo: &Repository, + reader: &CommitReader, + dest: &LocalRepo, + dirtree_id: &Sha256Digest, + dirmeta_id: &Sha256Digest, +) -> Result<()> { + let (_obj_id, dirtree_data) = reader + .lookup(dirtree_id)? + .ok_or_else(|| anyhow::anyhow!("Missing dirtree object {}", hex::encode(dirtree_id)))?; + let dirtree = ostree::OstreeDirTree::from_data(dirtree_data)?; + + let (_obj_id, dirmeta_data) = reader + .lookup(dirmeta_id)? + .ok_or_else(|| anyhow::anyhow!("Missing dirmeta object {}", hex::encode(dirmeta_id)))?; + // Write file objects first + for (_, file_checksum) in &dirtree.files { + export_file(repo, reader, dest, file_checksum)?; + } + + // Recurse into subdirectories (depth-first) + for (_, tree_checksum, meta_checksum) in &dirtree.dirs { + export_dir(repo, reader, dest, tree_checksum, meta_checksum)?; + } + + // Now write the metadata objects (all children exist) + dest.write_metadata_object(dirmeta_id, ostree::ObjectType::DirMeta, dirmeta_data)?; + dest.write_metadata_object(dirtree_id, ostree::ObjectType::DirTree, dirtree_data)?; + + Ok(()) +} + +fn export_file( + repo: &Repository, + reader: &CommitReader, + dest: &LocalRepo, + checksum: &Sha256Digest, +) -> Result<()> { + if dest.has_object(checksum, ostree::ObjectType::File) { + return Ok(()); + } + + let (maybe_obj_id, file_data) = reader + .lookup(checksum)? + .ok_or_else(|| anyhow::anyhow!("Missing file object {}", hex::encode(checksum)))?; + + let (sized_data, remaining_data) = ostree::split_sized_variant(file_data)?; + let header = ostree::OstreeFileHeader::from_zlib_sized(sized_data)?; + + let content = if let Some(obj_id) = maybe_obj_id { + repo::FileContent::External(std::fs::File::from(repo.open_object(obj_id)?)) + } else if !remaining_data.is_empty() { + repo::FileContent::Inline(remaining_data) + } else { + repo::FileContent::Empty + }; + + dest.write_file_object(checksum, &header, content) +} + /// Reads and parses an ostree commit object. /// /// `source` can be an ostree ref name or a commit ID / prefix. diff --git a/crates/composefs-ostree/src/repo.rs b/crates/composefs-ostree/src/repo.rs index e0f970bb..ee305a0c 100644 --- a/crates/composefs-ostree/src/repo.rs +++ b/crates/composefs-ostree/src/repo.rs @@ -5,6 +5,7 @@ //! HTTP-served repos ([`RemoteRepo`]). use anyhow::{Context, Result, anyhow, bail}; +use cap_std::fs::Dir; use configparser::ini::Ini; use flate2::read::DeflateDecoder; use gvariant::aligned_bytes::{AlignedBuf, TryAsAligned}; @@ -18,7 +19,7 @@ use std::mem::MaybeUninit; use std::{ fs::File, future::Future, - io::{Read, empty}, + io::{Read, Seek, Write, empty}, os::fd::{AsFd, OwnedFd}, path::Path, sync::Arc, @@ -30,9 +31,10 @@ use tokio_util::io::StreamReader; use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout}; use composefs::{ + erofs::format::{S_IFLNK, S_IFMT, S_IFREG}, fsverity::FsVerityHashValue, repository::Repository, - util::{ErrnoFilter, Sha256Digest, parse_sha256}, + util::{Sha256Digest, parse_sha256}, }; use crate::ostree::{ @@ -1012,8 +1014,9 @@ fn read_xattrs_from_path(fd: &impl AsFd) -> Result, Vec)>> { pub struct LocalRepo { repo: Arc>, mode: RepoMode, - dir: OwnedFd, - objects: OwnedFd, + dir: Dir, + objects: Dir, + tmp: Dir, } impl LocalRepo { @@ -1032,19 +1035,13 @@ impl LocalRepo { ) .with_context(|| format!("Cannot open ostree repository at {}", path.display()))?; - let configfd = openat( - &repofd, - "config", - OFlags::RDONLY | OFlags::CLOEXEC, - Mode::empty(), - ) - .with_context(|| format!("Cannot open ostree repo config file at {}", path.display()))?; + let dir = Dir::from_std_file(File::from(repofd)); let mut config_data = String::new(); - - File::from(configfd) + dir.open("config") + .context("Cannot open ostree repo config file")? .read_to_string(&mut config_data) - .with_context(|| "Can't read config file")?; + .context("Can't read config file")?; let mut config = Ini::new(); let map = config @@ -1062,24 +1059,19 @@ impl LocalRepo { .ok_or_else(|| anyhow!("No mode in [core] section in config"))? .parse()?; - let objectsfd = openat( - &repofd, - "objects", - OFlags::PATH | OFlags::CLOEXEC | OFlags::DIRECTORY, - 0o666.into(), - ) - .with_context(|| { - format!( - "Cannot open ostree repository objects directory at {}", - path.display() - ) - })?; + let objects = dir + .open_dir("objects") + .context("Cannot open ostree repository objects directory")?; + let tmp = dir + .open_dir("tmp") + .context("Cannot open ostree repository tmp directory")?; Ok(Self { repo: repo.clone(), mode, - dir: repofd, - objects: objectsfd, + dir, + objects, + tmp, }) } @@ -1107,30 +1099,22 @@ impl LocalRepo { let path1 = format!("refs/{}", ref_name); let path2 = format!("refs/heads/{}", ref_name); - let fd1 = openat( - &self.dir, - &path1, - OFlags::RDONLY | OFlags::CLOEXEC, - Mode::empty(), - ) - .filter_errno(Errno::NOENT) - .with_context(|| format!("Cannot open ostree ref at {}", path1))?; - - let fd = match fd1 { - Some(fd) => fd, - None => openat( - &self.dir, - &path2, - OFlags::RDONLY | OFlags::CLOEXEC, - Mode::empty(), - ) - .with_context(|| format!("Cannot open ostree ref at {}", path2))?, - }; - let mut buffer = String::new(); - File::from(fd) - .read_to_string(&mut buffer) - .with_context(|| "Can't read ref file")?; + let result = self.dir.open(&path1); + let mut file = match result { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => self + .dir + .open(&path2) + .with_context(|| format!("Cannot open ostree ref at {path2}"))?, + Err(e) => { + return Err( + anyhow::Error::from(e).context(format!("Cannot open ostree ref at {path1}")) + ); + } + }; + file.read_to_string(&mut buffer) + .context("Can't read ref file")?; Ok(parse_sha256(buffer.trim())?) } @@ -1235,6 +1219,344 @@ impl LocalRepo { } } +/// Adapter that implements `io::Write` by feeding bytes into a SHA-256 hasher. +pub(crate) struct HashWriter<'a>(pub &'a mut Sha256); + +impl Write for HashWriter<'_> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.update(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +const ZERO_TIMESTAMPS: rustix::fs::Timestamps = rustix::fs::Timestamps { + last_access: rustix::fs::Timespec { + tv_sec: 0, + tv_nsec: 0, + }, + last_modification: rustix::fs::Timespec { + tv_sec: 0, + tv_nsec: 0, + }, +}; + +fn make_tmp_name(prefix: &str) -> String { + let random: u64 = rand::random(); + format!(".tmp{prefix}.{random:016x}") +} + +/// Create a temporary symlink in `dirfd` with a unique name. +/// +/// Retries with a new random suffix on `EEXIST`. +fn make_tmp_symlink(dirfd: &impl AsFd, target: &str, prefix: &str) -> Result { + loop { + let name = make_tmp_name(prefix); + match rustix::fs::symlinkat(target, dirfd, name.as_str()) { + Ok(()) => return Ok(name), + Err(Errno::EXIST) => continue, + Err(e) => return Err(e.into()), + } + } +} + +/// Create a temporary file in `dirfd` with a unique name. +/// +/// Retries with a new random suffix on `EEXIST`. +fn make_tmp_file(dirfd: &impl AsFd, prefix: &str) -> Result<(String, OwnedFd)> { + loop { + let name = make_tmp_name(prefix); + match openat( + dirfd, + name.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC, + Mode::from_raw_mode(0o644), + ) { + Ok(fd) => return Ok((name, fd)), + Err(Errno::EXIST) => continue, + Err(e) => return Err(e.into()), + } + } +} + +/// Content source for writing a file object to an ostree repo. +#[derive(Debug)] +pub enum FileContent<'a> { + /// File content (copied via `copy_file_range` when possible). + External(File), + /// Inline byte data (small files). + Inline(&'a [u8]), + /// No content (e.g. symlinks handled separately, empty files). + Empty, +} + +impl LocalRepo { + /// Returns the repo mode. + pub fn mode(&self) -> RepoMode { + self.mode + } + + /// Returns the repo root directory. + pub fn dir(&self) -> &Dir { + &self.dir + } + + /// Check if an object already exists in the repository. + pub fn has_object(&self, checksum: &Sha256Digest, obj_type: ObjectType) -> bool { + let path = get_object_pathname(self.mode, checksum, obj_type); + self.objects.exists(&path) + } + + /// Ensure the two-character prefix directory exists under objects/. + fn ensure_objdir(&self, checksum: &Sha256Digest) -> Result<()> { + let prefix = format!("{:02x}", checksum[0]); + match self.objects.create_dir(&prefix) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(e.into()), + } + } + + /// Write a metadata object (dirtree, dirmeta, commit) to the repository. + /// + /// Verifies the SHA256 checksum of `data` before writing. + pub fn write_metadata_object( + &self, + checksum: &Sha256Digest, + obj_type: ObjectType, + data: &[u8], + ) -> Result<()> { + if self.has_object(checksum, obj_type) { + return Ok(()); + } + + let actual: Sha256Digest = Sha256::digest(data).into(); + if actual != *checksum { + bail!( + "metadata object checksum mismatch: expected {}, got {}", + hex::encode(checksum), + hex::encode(actual) + ); + } + + let tmpfd = openat( + &self.tmp, + ".", + OFlags::WRONLY | OFlags::TMPFILE | OFlags::CLOEXEC, + Mode::from_raw_mode(0o644), + ) + .context("Creating tmpfile for metadata object")?; + + rustix::io::write(&tmpfd, data)?; + + rustix::fs::futimens(&tmpfd, &ZERO_TIMESTAMPS)?; + + self.link_tmpfile(&tmpfd, checksum, obj_type) + } + + /// Write a file object to the repository. + /// + /// `content` provides the file data as either a raw fd (for external + /// objects — reflink is attempted first, falling back to copy) or a + /// byte slice (for inline data). + pub fn write_file_object( + &self, + checksum: &Sha256Digest, + header: &OstreeFileHeader, + content: FileContent<'_>, + ) -> Result<()> { + if self.has_object(checksum, ObjectType::File) { + return Ok(()); + } + + let is_symlink = (header.mode & S_IFMT as u32) == S_IFLNK as u32; + + if is_symlink && (self.mode == RepoMode::Bare || self.mode == RepoMode::BareUserOnly) { + return self.write_symlink_object(checksum, header); + } + + let tmpfd = openat( + &self.tmp, + ".", + OFlags::RDWR | OFlags::TMPFILE | OFlags::CLOEXEC, + Mode::from_raw_mode(0o644), + ) + .context("Creating tmpfile for file object")?; + + // Ostree file checksum = SHA256(regular_sized_header + content). + let regular_header = header.serialize_regular_sized(); + let mut hasher = Sha256::new(); + hasher.update(&*regular_header); + + if is_symlink { + // bare-user: symlinks stored as regular files with target + NUL + // note: Symlinks have no content in the checksum (the target is in the header). + let mut target_bytes = header.symlink_target.as_bytes().to_vec(); + target_bytes.push(0); + rustix::io::write(&tmpfd, &target_bytes)?; + } else { + match content { + FileContent::External(mut src) => { + let mut dst = File::from(tmpfd.try_clone()?); + std::io::copy(&mut src, &mut dst)?; + // We can't tee through the hasher here because that + // would defeat the copy_file_range/reflink optimization + // that std::io::copy uses. Instead, read back what was + // actually written to the destination. + let mut tmpfile = File::from(tmpfd.try_clone()?); + tmpfile.seek(std::io::SeekFrom::Start(0))?; + std::io::copy(&mut tmpfile, &mut HashWriter(&mut hasher))?; + } + FileContent::Inline(data) => { + rustix::io::write(&tmpfd, data)?; + hasher.update(data); + } + FileContent::Empty => {} + } + } + + let actual: Sha256Digest = hasher.finalize().into(); + if actual != *checksum { + bail!( + "file object checksum mismatch: expected {}, got {}", + hex::encode(checksum), + hex::encode(actual) + ); + } + + self.apply_file_metadata(&tmpfd, header)?; + + self.link_tmpfile(&tmpfd, checksum, ObjectType::File) + } + + fn write_symlink_object( + &self, + checksum: &Sha256Digest, + header: &OstreeFileHeader, + ) -> Result<()> { + self.ensure_objdir(checksum)?; + let path = get_object_pathname(self.mode, checksum, ObjectType::File); + let tmp_name = make_tmp_symlink(&self.tmp, &header.symlink_target, "symlink")?; + + if self.mode == RepoMode::Bare { + rustix::fs::chownat( + &self.tmp, + tmp_name.as_str(), + Some(rustix::process::Uid::from_raw(header.uid)), + Some(rustix::process::Gid::from_raw(header.gid)), + rustix::fs::AtFlags::SYMLINK_NOFOLLOW, + )?; + for (key, value) in &header.xattrs { + let key_str = std::str::from_utf8(key)?; + rustix::fs::lsetxattr( + format!("/proc/self/fd/{}/{tmp_name}", self.tmp.as_raw_fd()), + key_str, + value, + rustix::fs::XattrFlags::empty(), + )?; + } + } + + rustix::fs::utimensat( + &self.tmp, + tmp_name.as_str(), + &ZERO_TIMESTAMPS, + rustix::fs::AtFlags::SYMLINK_NOFOLLOW, + )?; + + self.tmp.rename(&tmp_name, &self.objects, &path)?; + Ok(()) + } + + fn apply_file_metadata(&self, fd: &OwnedFd, header: &OstreeFileHeader) -> Result<()> { + match self.mode { + RepoMode::Bare => { + rustix::fs::fchown( + fd, + Some(rustix::process::Uid::from_raw(header.uid)), + Some(rustix::process::Gid::from_raw(header.gid)), + )?; + rustix::fs::fchmod(fd, Mode::from_raw_mode(header.mode))?; + for (key, value) in &header.xattrs { + let key_str = std::str::from_utf8(key)?; + rustix::fs::fsetxattr(fd, key_str, value, rustix::fs::XattrFlags::empty())?; + } + } + RepoMode::BareUser => { + let meta = OstreeDirMeta { + uid: header.uid, + gid: header.gid, + mode: header.mode, + xattrs: header.xattrs.clone(), + }; + let meta_bytes = meta.serialize(); + rustix::fs::fsetxattr( + fd, + "user.ostreemeta", + &meta_bytes, + rustix::fs::XattrFlags::empty(), + )?; + if (header.mode & S_IFMT as u32) == S_IFREG as u32 { + let content_mode = (header.mode & 0o775) | 0o400; + rustix::fs::fchmod(fd, Mode::from_raw_mode(content_mode))?; + } + } + RepoMode::BareUserOnly => { + rustix::fs::fchmod(fd, Mode::from_raw_mode(header.mode))?; + } + _ => bail!("Archive mode not supported for export"), + } + + rustix::fs::futimens(fd, &ZERO_TIMESTAMPS)?; + + Ok(()) + } + + fn link_tmpfile( + &self, + tmpfd: &OwnedFd, + checksum: &Sha256Digest, + obj_type: ObjectType, + ) -> Result<()> { + self.ensure_objdir(checksum)?; + let path = get_object_pathname(self.mode, checksum, obj_type); + let proc_path = format!("/proc/self/fd/{}", tmpfd.as_raw_fd()); + match rustix::fs::linkat( + rustix::fs::CWD, + proc_path.as_str(), + &self.objects, + path.as_str(), + rustix::fs::AtFlags::SYMLINK_FOLLOW, + ) { + Ok(()) => Ok(()), + Err(Errno::EXIST) => Ok(()), + Err(e) => Err(e).context(format!("Linking object {}", hex::encode(checksum))), + } + } + + /// Write a ref file pointing to a commit checksum. + pub fn write_ref(&self, ref_name: &str, commit_id: &Sha256Digest) -> Result<()> { + let ref_path = format!("refs/heads/{ref_name}"); + + if let Some((parent, _)) = ref_path.rsplit_once('/') { + self.dir.create_dir_all(parent)?; + } + + let (tmp_name, tmpfd) = make_tmp_file(&self.tmp, "ref")?; + let content = format!("{}\n", hex::encode(commit_id)); + rustix::io::write(&tmpfd, content.as_bytes())?; + drop(tmpfd); + + self.tmp + .rename(&tmp_name, &self.dir, &ref_path) + .with_context(|| format!("Writing ref {ref_name}"))?; + + Ok(()) + } +} + impl OstreeRepo for LocalRepo { async fn resolve_ref(&self, ref_name: &str) -> Result { self.read_ref(ref_name)