From 9132edccfdc8cc77f59f45a083550feefc248581 Mon Sep 17 00:00:00 2001 From: Rolv Apneseth Date: Wed, 28 Oct 2026 09:01:36 +0000 Subject: [PATCH 1/3] Dockerfile: chmod 0555 /usr This is a workaround until the filesystem package ships the correct permissions for /usr, to match the expected permissions for root. Signed-off-by: Rolv Apneseth --- Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Dockerfile b/Dockerfile index 36a36986b..20ddfb2db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -360,6 +360,14 @@ rm -rf /var/lib rm -rf /var/cache rm -rf /run/rhsm +# TODO: drop once https://src.fedoraproject.org/rpms/filesystem/pull-request/44 or equivalent +# is widely available. For now, manually ensure that /usr has the correct permissions. +# Note also that this must run before the UKI is sealed in the sealed-uki stage below because +# it embeds the composefs digest of this stage's rootfs, so changing /usr afterwards leaves +# the image with a composefs digest that no longer matches the one in the UKI, breaking +# bootc install for UKI images. +chmod 0555 /usr + EORUN # Generate the sealed UKI in a separate stage From 87532fcf9b888857991bb91318464bbc2d562a5b Mon Sep 17 00:00:00 2001 From: Rolv Apneseth Date: Wed, 28 Oct 2026 10:31:19 +0000 Subject: [PATCH 2/3] ostree-ext: preserve directory metadata from OCI layers When building a derived image from OCI layers, the ostree `UnionFiles` checkout preserves directory metadata (mode, uid, gid) from the first layer that created the directory. This means later layers that only modify metadata (e.g. `RUN chmod 0555 /usr`) are silently lost. OCI semantics and composefs-rs require last-layer-wins, so add logic to reapply directory metadata after all layers are checked out. Additionally, copy the mode, uid and gid from /usr to the root (matching the approach by composefs-rs [1]) since OCI images don't reliably include root directory metadata, and container runtimes disagree on how to apply it when present. Unlike composefs-rs, mtime and xattrs are not copied: ostree dirmeta has no mtime field, and the SELinux relabelling pass assigns the root its own label. This should fix systemd-tmpfiles failures on read-only root systems with systemd v261+. See: [2] Assisted-by: llm Signed-off-by: Rolv Apneseth [1]: https://docs.rs/composefs/0.9.4/composefs/generic_tree/struct.FileSystem.html#method.copy_root_metadata_from_usr [2]: https://github.com/coreos/fedora-coreos-tracker/issues/2238 --- crates/ostree-ext/src/container/store.rs | 238 +++++++++++++++++- crates/ostree-ext/tests/it/main.rs | 138 ++++++++++ .../booted/readonly/040-test-baseconfigs.nu | 8 - .../booted/readonly/055-test-root-dirmeta.nu | 29 +++ 4 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 tmt/tests/booted/readonly/055-test-root-dirmeta.nu diff --git a/crates/ostree-ext/src/container/store.rs b/crates/ostree-ext/src/container/store.rs index 9316d8547..293cfc7d0 100644 --- a/crates/ostree-ext/src/container/store.rs +++ b/crates/ostree-ext/src/container/store.rs @@ -158,7 +158,9 @@ use oci_spec::image::{ }; use ocidir::oci_spec::distribution::Reference; use ostree::prelude::{Cast, FileEnumeratorExt, FileExt, ToVariant}; -use ostree::{gio, glib}; +use ostree::{DirMetaParsed, gio, glib}; +use rustix::fs::{Gid, Mode, OFlags, ResolveFlags, Uid}; +use rustix::io::Errno; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Write as _; use std::iter::FromIterator; @@ -642,6 +644,225 @@ fn cleanup_root(root: &Dir) -> Result<()> { Ok(()) } +/// Collect the dirmeta checksum for every directory, keyed by path relative +/// to the commit root. +fn collect_dirmeta_checksums( + dir: &ostree::RepoFile, + path: &Utf8Path, + out: &mut HashMap, + cancellable: Option<&gio::Cancellable>, +) -> Result<()> { + dir.ensure_resolved()?; + + let checksum = dir + .tree_get_metadata_checksum() + .ok_or_else(|| anyhow!("Missing metadata checksum for {path}"))?; + out.insert(path.to_owned(), checksum); + + // Recurse over child directories, without following symlinks + let query_flags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS; + let files = + dir.enumerate_children("standard::name,standard::type", query_flags, cancellable)?; + while let Some(child_info) = files.next_file(cancellable)? { + if child_info.file_type() != gio::FileType::Directory { + continue; + } + let name = child_info.name(); + let name = name.to_str().expect("UTF-8 ostree name"); + let child = files + .child(&child_info) + .downcast::() + .expect("downcast"); + collect_dirmeta_checksums(&child, &path.join(name), out, cancellable)?; + } + + Ok(()) +} + +/// Apply directory mode and ownership from an ostree DirMeta to a filesystem +/// directory. +/// +/// We intentionally skip xattrs here since the SELinux relabelling pass in +/// `write_dfd_to_mtree` overwrites `security.selinux` on every directory, +/// and no other directory xattrs are meaningful in practice. +fn apply_dirmeta( + dir: impl rustix::fd::AsFd, + metadata: &ostree::DirMetaParsed, + should_fchown: bool, +) -> Result<()> { + if should_fchown { + rustix::fs::fchown( + &dir, + Some(Uid::from_raw(metadata.uid)), + Some(Gid::from_raw(metadata.gid)), + ) + .context("fchown")?; + } + let mode = Mode::from_raw_mode(metadata.mode); + rustix::fs::fchmod(&dir, mode).context("fchmod")?; + Ok(()) +} + +/// The tar import (`ostree commit --tar-autocreate-parents`) synthesizes a +/// parent directory for every path a layer's tar doesn't mention, with a +/// default mode of 0755. Those are indistinguishable from an explicit +/// `chmod 0755`, so they are treated as if the layer never mentioned the +/// directory. The trade-off is that a deliberate chmod to exactly 0755 (or +/// an ownership-only change, since only the mode is checked) is skipped +/// too. Telling these apart would require the tar import to record which +/// entries were explicit. +fn is_default_mode(metadata: &ostree::DirMetaParsed) -> bool { + metadata.mode & 0o7777 == 0o755 +} + +/// Load the dirmeta for `checksum`, returning `None` if it matches the tar +/// import's synthesized-parent default, so it cannot override metadata that +/// an earlier layer or the base set explicitly. +fn load_explicit_dirmeta( + repo: &ostree::Repo, + path: &Utf8Path, + checksum: &str, +) -> Result> { + let parsed = repo + .read_dirmeta(checksum) + .with_context(|| format!("Loading dirmeta {checksum}"))?; + if is_default_mode(&parsed) { + tracing::debug!("Skipping {path}, dirmeta matches the tar import default mode"); + return Ok(None); + } + Ok(Some(parsed)) +} + +/// A `UnionFiles` checkout keeps the directory metadata from the first layer +/// that created the directory, so later layers that only change a directory's +/// mode or ownership (e.g. `RUN chmod 0555 /usr`) are silently lost. +/// OCI semantics (and composefs-rs) require last-layer-wins, so we reapply +/// directory metadata after all layers have been checked out. The last +/// layer to explicitly mention a directory wins. Entries matching the tar +/// import's synthesized-parent default are ignored (see `is_default_mode()`). +/// +/// For efficiency, we first collect only the cheap dirmeta *checksums* from +/// each layer. Only directories whose final checksum differs from the base +/// layer need to be updated. +fn apply_layer_dirmeta( + repo: &ostree::Repo, + base_commit: Option<&str>, + layer_commits: &[LayerRef], + root: &Dir, + should_fchown: bool, + cancellable: Option<&gio::Cancellable>, +) -> Result<()> { + if layer_commits.is_empty() { + return Ok(()); + } + + // Collect the base layer's dirmeta checksums to skip unchanged directories + let base_checksums = match base_commit { + Some(base) => { + let mut base_map = HashMap::new(); + let (base_root, _) = repo.read_commit(base, cancellable)?; + let base_root = base_root.downcast::().expect("downcast"); + collect_dirmeta_checksums(&base_root, Utf8Path::new("."), &mut base_map, cancellable) + .context("Reading base directory metadata checksums")?; + base_map + } + None => HashMap::new(), + }; + + // Walk the derived layers in order. The last layer to explicitly mention + // a directory wins (see is_default_mode()). + let mut final_checksums: BTreeMap = BTreeMap::new(); + for lc in layer_commits { + let (commit_root, _) = repo.read_commit(&lc.commit, cancellable)?; + let commit_root = commit_root + .downcast::() + .expect("downcast"); + let mut layer_checksums = HashMap::new(); + collect_dirmeta_checksums( + &commit_root, + Utf8Path::new("."), + &mut layer_checksums, + cancellable, + ) + .with_context(|| format!("Reading directory metadata checksums of {}", lc.commit))?; + for (path, checksum) in layer_checksums { + // A synthesized parent in this layer must not override metadata + // that an earlier layer set explicitly. + let overrides_explicit = final_checksums + .get(&path) + .is_some_and(|existing| existing.as_str() != checksum.as_str()); + if overrides_explicit + && load_explicit_dirmeta(repo, &path, checksum.as_str()) + .with_context(|| format!("Reading dirmeta in layer {}", lc.commit))? + .is_none() + { + continue; + } + final_checksums.insert(path, checksum); + } + } + + // Only parse + apply directories whose checksum differs from the base. + // Descendants sort after their ancestors, so reverse iteration applies + // children first and a parent that drops search permission can't block + // their updates. + for (path, checksum) in final_checksums.iter().rev() { + if base_checksums + .get(path) + .is_some_and(|s| s.as_str() == checksum.as_str()) + { + tracing::debug!("Skipping {path}, metadata checksum is the same as the base layer"); + continue; + } + let Some(parsed) = load_explicit_dirmeta(repo, path, checksum.as_str())? else { + continue; + }; + tracing::debug!("Applying directory metadata for {path}"); + + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let resolve = ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS; + let fd = match rustix::fs::openat2(root, path.as_std_path(), flags, Mode::empty(), resolve) + { + Ok(fd) => fd, + // Gone, replaced by a later layer, or unreachable because an + // ancestor lacks search permission + Err(e @ (Errno::NOENT | Errno::NOTDIR | Errno::LOOP | Errno::ACCESS)) => { + tracing::debug!("Skipping {path}: {e}"); + continue; + } + Err(e) => return Err(e).with_context(|| format!("Getting FD for {path}")), + }; + apply_dirmeta(fd, &parsed, should_fchown) + .with_context(|| format!("Updating metadata for {path}"))?; + } + Ok(()) +} + +/// Copy the `/usr` directory mode and ownership to the root, like composefs-rs's +/// `copy_root_metadata_from_usr`. OCI images don't reliably include root dir +/// metadata, so layer root metadata (including an explicit `.` entry) is +/// intentionally overridden. Must run before the merged tree is committed, so +/// the root still gets its own SELinux label (root_t). +/// +/// See: +/// - +/// - +fn normalize_root_dirmeta(root: &Dir, should_fchown: bool) -> Result<()> { + let usr = root.symlink_metadata("usr").context("looking up /usr")?; + anyhow::ensure!(usr.is_dir(), "image has no /usr directory"); + // `root` may be an `O_PATH` fd, on which fchmod/fchown fail with EBADF. + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC; + let rootfd = rustix::fs::openat(root, ".", flags, Mode::empty()).context("opening root")?; + + let metadata = DirMetaParsed { + uid: usr.uid(), + gid: usr.gid(), + mode: usr.mode(), + xattrs: vec![], + }; + apply_dirmeta(rootfd, &metadata, should_fchown).context("applying /usr metadata to root") +} + impl ImageImporter { /// The metadata key used in ostree commit metadata to serialize const CACHED_KEY_MANIFEST_DIGEST: &'static str = "ostree-ext.cached.manifest-digest"; @@ -1431,6 +1652,21 @@ impl ImageImporter { cleanup_root(&root_dir)?; + // Apply the layer uid/gid only for bare checkouts, where the + // checked-out files carry real ownership. + let should_fchown = checkout_mode == ostree::RepoCheckoutMode::None; + apply_layer_dirmeta( + repo, + base_commit, + layer_commits, + &root_dir, + should_fchown, + cancellable, + ) + .context("applying layer directory metadata")?; + normalize_root_dirmeta(&root_dir, should_fchown) + .context("normalizing root dir metadata")?; + let mt = ostree::MutableTree::new(); repo.write_dfd_to_mtree( (*td).as_raw_fd(), diff --git a/crates/ostree-ext/tests/it/main.rs b/crates/ostree-ext/tests/it/main.rs index 8f973d3c4..397177e51 100644 --- a/crates/ostree-ext/tests/it/main.rs +++ b/crates/ostree-ext/tests/it/main.rs @@ -60,6 +60,33 @@ fn assert_err_contains(r: Result, s: impl AsRef) { } } +/// Verify that a commit's root directory mode/uid/gid matches `/usr`. +fn assert_root_dirmeta_matches_usr(repo: &ostree::Repo, commit: &str) { + let root = repo.read_commit(commit, gio::Cancellable::NONE).unwrap().0; + let root = root.downcast::().unwrap(); + root.ensure_resolved().unwrap(); + + let usr = root.resolve_relative_path("usr"); + let usr = usr.downcast::().unwrap(); + usr.ensure_resolved().unwrap(); + + let query = |f: &ostree::RepoFile| { + let info = f + .query_info( + "unix::mode,unix::uid,unix::gid", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + gio::Cancellable::NONE, + ) + .unwrap(); + ( + info.attribute_uint32("unix::mode"), + info.attribute_uint32("unix::uid"), + info.attribute_uint32("unix::gid"), + ) + }; + assert_eq!(query(&root), query(&usr), "root dirmeta should match /usr"); +} + static TEST_REGISTRY: LazyLock = LazyLock::new(|| match std::env::var_os("TEST_REGISTRY") { Some(t) => t.to_str().unwrap().to_owned(), @@ -709,6 +736,7 @@ async fn test_export_as_container_nonderived() -> Result<()> { let reimported = fixture.must_import(&dest).await?; let reimport_ls = fixture::ostree_ls(fixture.destrepo(), &reimported.merge_commit).unwrap(); similar_asserts::assert_eq!(initimport_ls, reimport_ls); + assert_root_dirmeta_matches_usr(fixture.destrepo(), &reimported.merge_commit); Ok(()) } @@ -1024,6 +1052,85 @@ async fn test_export_etc_remapping() -> Result<()> { Ok(()) } +/// Verify that a derived layer that changes `/usr` mode (e.g. `RUN chmod 0555 /usr`) +/// is preserved through the merge commit, even when a later layer only +/// implies `/usr` by adding a file under it. +#[tokio::test] +async fn test_derived_layer_usr_mode() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + let src_imgref = fixture.export_container().await.unwrap().0; + + // Build a derived image with a layer that changes /usr to mode 0555. + let derived_path = &fixture.path.join("derived-usr-mode.oci"); + oci_clone(src_imgref.name.as_str(), derived_path) + .await + .unwrap(); + ostree_ext::integrationtest::generate_derived_oci_from_tar( + derived_path, + |w| { + let mut layer_tar = tar::Builder::new(w); + append_tar_dir(&mut layer_tar, "usr", 0o555)?; + layer_tar.finish()?; + Ok(()) + }, + None, + None, + )?; + // A later layer only implies /usr, so the tar import synthesizes a + // default 0755 parent which must not mask the explicit 0555. + ostree_ext::integrationtest::generate_derived_oci_from_tar( + derived_path, + |w| { + let mut layer_tar = tar::Builder::new(w); + append_tar_file(&mut layer_tar, "usr/bin/newfile", b"newfile")?; + layer_tar.finish()?; + Ok(()) + }, + None, + None, + )?; + + let derived_imgref = ImageReference { + transport: Transport::OciDir, + name: derived_path.to_string(), + }; + let imp = fixture.must_import(&derived_imgref).await?; + + // Verify /usr has mode 0555 in the merge commit. + let root = fixture + .destrepo() + .read_commit(&imp.merge_commit, gio::Cancellable::NONE)? + .0; + let usr = root.resolve_relative_path("usr"); + let usr = usr.downcast::().unwrap(); + usr.ensure_resolved().unwrap(); + + let info = usr.query_info( + "unix::mode", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + gio::Cancellable::NONE, + )?; + let mode = info.attribute_uint32("unix::mode") & 0o7777; // mask to get only permissions bits + assert_eq!( + mode, 0o555, + "/usr mode should be 0555 after derived layer, got '{mode:#o}'" + ); + + let newfile = root.resolve_relative_path("usr/bin/newfile"); + let newfile_info = newfile.query_info( + "standard::type", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + gio::Cancellable::NONE, + )?; + assert_eq!(newfile_info.file_type(), gio::FileType::Regular); + + assert_root_dirmeta_matches_usr(fixture.destrepo(), &imp.merge_commit); + Ok(()) +} + #[tokio::test] async fn test_unencapsulate_unbootable() -> Result<()> { if !check_skopeo() { @@ -1653,6 +1760,37 @@ async fn test_non_ostree() -> Result<()> { similar_asserts::assert_eq!(&src_manifest, &dest_manifest); } assert_eq!(imp.manifest_digest, src_digest); + assert_root_dirmeta_matches_usr(fixture.destrepo(), &imp.merge_commit); + Ok(()) +} + +fn append_tar_dir( + builder: &mut tar::Builder, + path: &str, + mode: u32, +) -> Result<()> { + let mut h = tar::Header::new_gnu(); + h.set_uid(0); + h.set_gid(0); + h.set_size(0); + h.set_mode(mode); + h.set_entry_type(tar::EntryType::Directory); + builder.append_data(&mut h, path, std::io::empty())?; + Ok(()) +} + +fn append_tar_file( + builder: &mut tar::Builder, + path: &str, + data: &[u8], +) -> Result<()> { + let mut h = tar::Header::new_gnu(); + h.set_uid(0); + h.set_gid(0); + h.set_size(data.len().try_into().unwrap()); + h.set_mode(0o644); + h.set_entry_type(tar::EntryType::Regular); + builder.append_data(&mut h, path, std::io::Cursor::new(data))?; Ok(()) } diff --git a/tmt/tests/booted/readonly/040-test-baseconfigs.nu b/tmt/tests/booted/readonly/040-test-baseconfigs.nu index a8f6e510d..29ee0e3f1 100644 --- a/tmt/tests/booted/readonly/040-test-baseconfigs.nu +++ b/tmt/tests/booted/readonly/040-test-baseconfigs.nu @@ -21,10 +21,6 @@ for config in $configs { let fstype = $mnt.filesystems.0.fstype assert equal $fstype "overlay" "/etc should be mounted as overlay when etc-transient is active" - print "# Checking / has 755 permissions (overlay upper dir must not block traversal)" - let perms = (stat -c "%a" / | str trim) - assert equal $perms "755" "/ should have 755 permissions" - print "# Checking /etc is writable (transient overlay)" let result = (do { ^touch /etc/bootc-baseconfig-test } | complete) assert equal $result.exit_code 0 "/etc should be writable with etc-transient" @@ -36,10 +32,6 @@ for config in $configs { let fstype = $mnt.filesystems.0.fstype assert equal $fstype "overlay" "/ should be mounted as overlay when root-transient is active" - print "# Checking / has 755 permissions" - let perms = (stat -c "%a" / | str trim) - assert equal $perms "755" "/ should have 755 permissions" - print "# Checking / is writable (transient overlay)" let result = (do { ^touch /bootc-baseconfig-root-test } | complete) assert equal $result.exit_code 0 "/ should be writable with root-transient" diff --git a/tmt/tests/booted/readonly/055-test-root-dirmeta.nu b/tmt/tests/booted/readonly/055-test-root-dirmeta.nu new file mode 100644 index 000000000..376422d8a --- /dev/null +++ b/tmt/tests/booted/readonly/055-test-root-dirmeta.nu @@ -0,0 +1,29 @@ +# Verify that the root dir's uid, gid, and permissions match /usr. +# +# See: https://github.com/coreos/fedora-coreos-tracker/issues/2238 +use std assert +use tap.nu + +tap begin "verify root dir metadata matches /usr" + +let root_meta = (stat -c "mode: %a, gid: %g, uid: %u" / | str trim) +let usr_meta = (stat -c "mode: %a, gid: %g, uid: %u" /usr | str trim) + +print $"# root: ($root_meta)" +print $"# /usr: ($usr_meta)" + +assert equal $root_meta $usr_meta $"root dir metadata \(($root_meta)\) should match /usr \(($usr_meta)\)" + +# When this system was upgraded from a published base image (test-upgrade flow), +# the import ran with the older bootc from that image, which predates the +# directory metadata preservation. Both / and /usr end up 0755 there. +# Only assert the strict mode on systems imported by a bootc that has the fix. +# TODO: once published base images ship the metadata fix, compare +# /var/bootc-pre-upgrade-version against the fixed version instead (like 031 does). +if not ("/var/bootc-pre-upgrade-version" | path exists) { + assert equal $root_meta "mode: 555, gid: 0, uid: 0" $"root dir metadata \(($root_meta)\) should match expected \(mode: 555, gid: 0, uid: 0\)" +} else { + print "# skipping strict 0555 check: imported by pre-fix bootc during upgrade" +} + +tap ok From 1f549ccf7b850ae21c177bad4b5ddd753e0d2a52 Mon Sep 17 00:00:00 2001 From: Rolv Apneseth Date: Fri, 30 Oct 2026 17:11:58 +0000 Subject: [PATCH 3/3] initramfs: match overlay upper dir mode to lower root fs overlayfs takes the merged directory metadata from the upper dir, not the lower [1]. Since we were creating the upper with a hardcoded 0755, every transient overlay showed 0755 for the merged directory, hiding whatever mode the image committed. This matters most for the merged root (root-transient) and /usr overlay. With the previous commit in ostree-ext copying /usr metadata to the root commit, images can now ship / as 0555. The hardcoded 0755 upper masked that after the overlay. Create the upper dir with the lower root's mode so the merged view keeps the image permissions. Persistent overlays (/etc) keep the previous hard-coded 0755. This goes further than composefs-rs so that transient roots match the committed metadata. Assisted-by: llm Signed-off-by: Rolv Apneseth [1]: https://github.com/composefs/composefs-rs/issues/287 --- crates/initramfs/src/lib.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index ed989c57d..5e53d22b5 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -270,18 +270,17 @@ fn mount_tmpfs_for_overlay() -> Result { } /// Build an overlayfs fsmount fd from an existing state dir (upper+work). -/// -/// upper is 0755: the merged view inherits permissions from upperdir, so 0700 -/// would make the mountpoint inaccessible to non-root processes. work is -/// kernel-internal and never visible; 0700 is fine. +/// The merged view takes its dir metadata from the upper, hence the explicit +/// upper mode. The work dir is kernel-internal; 0700 is fine. /// See: fn build_overlay_fd( base: impl AsFd, state: impl AsFd, source: &str, + upper_mode: rustix::fs::Mode, mount_attr_flags: Option, ) -> Result { - let upper = ensure_dir(state.as_fd(), "upper", Some(0o755.into()))?; + let upper = ensure_dir(state.as_fd(), "upper", Some(upper_mode))?; let work = ensure_dir(state.as_fd(), "work", Some(0o700.into()))?; let overlayfs = FsHandle::open("overlay")?; @@ -306,7 +305,7 @@ fn overlay_state( source: &str, mount_attr_flags: Option, ) -> Result<()> { - let fs = build_overlay_fd(&base, state, source, mount_attr_flags)?; + let fs = build_overlay_fd(&base, state, source, 0o755.into(), mount_attr_flags)?; mount_at_wrapper(fs, base, ".").context("Moving mount") } @@ -333,7 +332,10 @@ pub fn overlay_transient( ) -> Result { let tmpfs = mount_tmpfs_for_overlay()?; let state = prepare_mount(tmpfs)?; - build_overlay_fd(base, state, source, mount_attr_flags) + // The merged dir metadata comes from the upper, so match the lower root + let stat = rustix::fs::fstat(base.as_fd())?; + let upper_mode = rustix::fs::Mode::from_raw_mode(stat.st_mode); + build_overlay_fd(base, state, source, upper_mode, mount_attr_flags) } #[context("Opening rootfs")]