From 8c43d93ed494c1ddfd5af60bab09447d5ec96c8d Mon Sep 17 00:00:00 2001 From: Peter Siegel Date: Mon, 16 Mar 2026 22:13:16 +0100 Subject: [PATCH 1/3] tar: Drop PAX path/linkpath headers that bypass /etc remap PAX extended headers take precedence over basic tar header fields per POSIX. When a container layer contains PAX `path` or `linkpath` headers (e.g. for non-ASCII filenames), they override the remapped path written to the basic header, causing files that should land under /usr/etc to remain under /etc. Filter out `path` and `linkpath` from PAX extensions before writing the output entry. The tar crate regenerates them from the remapped path passed to append_data/append_link. Signed-off-by: Peter Siegel --- crates/ostree-ext/src/tar/write.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/ostree-ext/src/tar/write.rs b/crates/ostree-ext/src/tar/write.rs index c1ca69666..d38608b0d 100644 --- a/crates/ostree-ext/src/tar/write.rs +++ b/crates/ostree-ext/src/tar/write.rs @@ -48,13 +48,23 @@ pub(crate) fn copy_entry( }; let mut header = entry.header().clone(); if let Some(headers) = entry.pax_extensions()? { - let extensions = headers + // Filter out `path` and `linkpath` from PAX extensions. The tar crate + // will regenerate them from the (possibly remapped) path we pass to + // append_data/append_link. Keeping the originals would override our + // remap (e.g. /etc -> /usr/etc) since PAX headers take precedence + // over basic tar header fields per POSIX. + let extensions: Vec<_> = headers .map(|ext| { let ext = ext?; Ok((ext.key()?, ext.value_bytes())) }) - .collect::>>()?; - dest.append_pax_extensions(extensions.as_slice().iter().copied())?; + .collect::>>()? + .into_iter() + .filter(|(key, _)| *key != "path" && *key != "linkpath") + .collect(); + if !extensions.is_empty() { + dest.append_pax_extensions(extensions.iter().copied())?; + } } // Need to use the entry.link_name() not the header.link_name() From c157a99f8431b9f8c9355212a7a5d31070c602e5 Mon Sep 17 00:00:00 2001 From: Peter Siegel Date: Mon, 16 Mar 2026 22:13:21 +0100 Subject: [PATCH 2/3] tar: Add regression test for PAX path remap with non-ASCII filenames Verifies that PAX `path` headers (as produced by Docker/BuildKit for non-ASCII filenames) do not bypass the /etc -> /usr/etc remap. Checks both that no unremapped /etc PAX headers remain in the output and that the remapped file appears under usr/etc. Signed-off-by: Peter Siegel --- crates/ostree-ext/src/tar/write.rs | 69 ++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/ostree-ext/src/tar/write.rs b/crates/ostree-ext/src/tar/write.rs index d38608b0d..be8a48543 100644 --- a/crates/ostree-ext/src/tar/write.rs +++ b/crates/ostree-ext/src/tar/write.rs @@ -630,4 +630,73 @@ mod tests { assert!(!destdir.join("blah").exists()); Ok(()) } + + /// Regression test: PAX `path` headers (used for non-ASCII filenames) + /// must not bypass the /etc -> /usr/etc remap, since PAX takes + /// precedence over basic tar headers per POSIX. + #[tokio::test] + async fn tar_filter_pax_etc_remap() -> Result<()> { + let tempd = tempfile::tempdir()?; + let src_tar_path = tempd.path().join("src.tar"); + let pax_path = "etc/ssl/certs/Főtanúsítvány.pem"; + + // Build a tar with an explicit PAX `path` under etc/, matching how + // Docker/BuildKit produces layers for non-ASCII filenames. + { + let mut builder = tar::Builder::new(std::fs::File::create(&src_tar_path)?); + let data = b"cert"; + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_entry_type(tar::EntryType::Regular); + header.set_cksum(); + builder.append_pax_extensions([("path", pax_path.as_bytes())].into_iter())?; + builder.append_data(&mut header, pax_path, &data[..])?; + builder.into_inner()?; + } + + let mut dest = Vec::new(); + let src = tokio::io::BufReader::new(tokio::fs::File::open(&src_tar_path).await?); + let cap_tmpdir = Dir::open_ambient_dir(&tempd, cap_std::ambient_authority())?; + filter_tar_async( + src, + oci_image::MediaType::ImageLayer, + &mut dest, + &Default::default(), + cap_tmpdir, + ) + .await?; + + // Check the raw PAX headers in the output. We cannot use unpack() + // because the Rust tar crate resolves PAX-vs-GNU conflicts + // differently than libarchive/ostree (which gives PAX precedence). + let mut found_remapped = false; + let mut archive = tar::Archive::new(Cursor::new(dest.as_slice())); + for entry in archive.entries()? { + let mut entry = entry?; + let entry_path = entry.path()?; + let entry_path = entry_path.to_string_lossy(); + let entry_path = entry_path.trim_start_matches("./"); + if entry_path == format!("usr/{pax_path}") { + found_remapped = true; + } + if let Some(pax) = entry.pax_extensions()? { + for ext in pax.flatten() { + if let Ok("path" | "linkpath") = ext.key() { + let value = String::from_utf8_lossy(ext.value_bytes()); + let clean = value.trim_start_matches("./").trim_end_matches('\0'); + assert!( + !clean.starts_with("etc/") && clean != "etc", + "PAX header still contains unremapped /etc path: {value}" + ); + } + } + } + } + assert!( + found_remapped, + "Expected remapped file at usr/{pax_path} not found in output" + ); + Ok(()) + } } From 9742f058bb9ae9c3903fa9be42ea8a661cfd738e Mon Sep 17 00:00:00 2001 From: Peter Siegel Date: Mon, 16 Mar 2026 22:35:03 +0100 Subject: [PATCH 3/3] chore: adopt gemini code suggestions Signed-off-by: Peter Siegel --- crates/ostree-ext/src/tar/write.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/ostree-ext/src/tar/write.rs b/crates/ostree-ext/src/tar/write.rs index be8a48543..ec2e1b41f 100644 --- a/crates/ostree-ext/src/tar/write.rs +++ b/crates/ostree-ext/src/tar/write.rs @@ -53,17 +53,16 @@ pub(crate) fn copy_entry( // append_data/append_link. Keeping the originals would override our // remap (e.g. /etc -> /usr/etc) since PAX headers take precedence // over basic tar header fields per POSIX. - let extensions: Vec<_> = headers - .map(|ext| { - let ext = ext?; - Ok((ext.key()?, ext.value_bytes())) - }) - .collect::>>()? - .into_iter() - .filter(|(key, _)| *key != "path" && *key != "linkpath") - .collect(); - if !extensions.is_empty() { - dest.append_pax_extensions(extensions.iter().copied())?; + let mut extensions_to_keep = Vec::new(); + for ext_res in headers { + let ext = ext_res?; + let key = ext.key()?; + if key != "path" && key != "linkpath" { + extensions_to_keep.push((key, ext.value_bytes())); + } + } + if !extensions_to_keep.is_empty() { + dest.append_pax_extensions(extensions_to_keep)?; } } @@ -681,7 +680,8 @@ mod tests { found_remapped = true; } if let Some(pax) = entry.pax_extensions()? { - for ext in pax.flatten() { + for ext_res in pax { + let ext = ext_res?; if let Ok("path" | "linkpath") = ext.key() { let value = String::from_utf8_lossy(ext.value_bytes()); let clean = value.trim_start_matches("./").trim_end_matches('\0');