diff --git a/crates/ostree-ext/src/tar/write.rs b/crates/ostree-ext/src/tar/write.rs index c1ca69666..ec2e1b41f 100644 --- a/crates/ostree-ext/src/tar/write.rs +++ b/crates/ostree-ext/src/tar/write.rs @@ -48,13 +48,22 @@ pub(crate) fn copy_entry( }; let mut header = entry.header().clone(); if let Some(headers) = entry.pax_extensions()? { - let extensions = headers - .map(|ext| { - let ext = ext?; - Ok((ext.key()?, ext.value_bytes())) - }) - .collect::>>()?; - dest.append_pax_extensions(extensions.as_slice().iter().copied())?; + // 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 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)?; + } } // Need to use the entry.link_name() not the header.link_name() @@ -620,4 +629,74 @@ 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_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'); + 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(()) + } }