fix(extract): defer directory modes and hard links during extraction
tar-rs applies a directory's tar mode as soon as the entry is extracted, so images shipping read-only directories abort on their own contents: fedora:latest's dr-xr-xr-x ca-trust tree fails with EACCES on etc/pki/ca-trust/extracted/pem/directory-hash/002c0b4f.0. Directory modes are now recorded and applied once after the last layer is written. Hard links were canonicalized inline, failing with ENOENT when the target appears later in the tar, lives in an earlier layer, or exists nowhere. Links are now applied after the file pass and fall back to a verbatim symlink with a warning when the target is absent, matching Docker/podman behaviour for these entries. Verified against a real fedora:latest layer: extraction previously aborted at the first directory-hash symlink, now all 438 entries land with tls-ca-bundle.pem intact.
This commit is contained in:
+331
-21
@@ -3,7 +3,8 @@ use anyhow::{Context, Result};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Extract a tarball to the specified directory
|
||||
pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> {
|
||||
@@ -30,16 +31,17 @@ fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> {
|
||||
.with_context(|| format!("Failed to open tarball: {}", tarball.display()))?;
|
||||
|
||||
let reader = BufReader::new(file);
|
||||
let mut fixups = DeferredFixups::default();
|
||||
|
||||
// Detect compression format from filename
|
||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
extract_gz(reader, dest)?;
|
||||
extract_gz(reader, dest, &mut fixups)?;
|
||||
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
||||
extract_xz(reader, dest)?;
|
||||
extract_xz(reader, dest, &mut fixups)?;
|
||||
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") {
|
||||
extract_zst(reader, dest)?;
|
||||
extract_zst(reader, dest, &mut fixups)?;
|
||||
} else if filename.ends_with(".tar") {
|
||||
extract_tar(reader, dest)?;
|
||||
extract_tar(reader, dest, &mut fixups)?;
|
||||
} else {
|
||||
// Try to detect from magic bytes
|
||||
let mut magic = [0u8; 6];
|
||||
@@ -51,30 +53,30 @@ fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> {
|
||||
// gzip magic
|
||||
drop(peek_reader);
|
||||
let file = File::open(tarball)?;
|
||||
extract_gz(BufReader::new(file), dest)?;
|
||||
extract_gz(BufReader::new(file), dest, &mut fixups)?;
|
||||
}
|
||||
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => {
|
||||
// xz magic
|
||||
drop(peek_reader);
|
||||
let file = File::open(tarball)?;
|
||||
extract_xz(BufReader::new(file), dest)?;
|
||||
extract_xz(BufReader::new(file), dest, &mut fixups)?;
|
||||
}
|
||||
[0x28, 0xb5, 0x2f, 0xfd, ..] => {
|
||||
// zstd magic
|
||||
drop(peek_reader);
|
||||
let file = File::open(tarball)?;
|
||||
extract_zst(BufReader::new(file), dest)?;
|
||||
extract_zst(BufReader::new(file), dest, &mut fixups)?;
|
||||
}
|
||||
_ => {
|
||||
// Assume uncompressed tar
|
||||
drop(peek_reader);
|
||||
let file = File::open(tarball)?;
|
||||
extract_tar(BufReader::new(file), dest)?;
|
||||
extract_tar(BufReader::new(file), dest, &mut fixups)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
apply_fixups(dest, &mut fixups)
|
||||
}
|
||||
|
||||
/// Extract a multi-layer OCI image
|
||||
@@ -121,6 +123,11 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
|
||||
let manifest =
|
||||
std::fs::read_to_string(&manifest_path).context("Failed to read layers.manifest")?;
|
||||
|
||||
// One fixup set across all layers: later layers routinely write into
|
||||
// directories the base layer ships read-only, and hard-link targets
|
||||
// routinely live in an earlier layer.
|
||||
let mut fixups = DeferredFixups::default();
|
||||
|
||||
// Apply each layer in order with full whiteout handling.
|
||||
for layer_name in manifest.lines() {
|
||||
let layer_path = temp_dir.path().join(layer_name);
|
||||
@@ -128,16 +135,16 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
veprintln!("Extracting layer: {}", layer_name);
|
||||
extract_oci_layer(&layer_path, dest)?;
|
||||
extract_oci_layer(&layer_path, dest, &mut fixups)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
apply_fixups(dest, &mut fixups)
|
||||
}
|
||||
|
||||
/// Decompress and extract one OCI layer tarball into `dest`, honouring
|
||||
/// whiteout markers. Compression is inferred from the filename then from
|
||||
/// magic bytes.
|
||||
fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
|
||||
fn extract_oci_layer(layer_path: &Path, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
|
||||
use std::io::Read;
|
||||
let layer_name = layer_path
|
||||
.file_name()
|
||||
@@ -152,18 +159,21 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
|
||||
tar::Archive::new(flate2::read::GzDecoder::new(reader)),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
fixups,
|
||||
)
|
||||
} else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") {
|
||||
extract_with_progress(
|
||||
tar::Archive::new(xz2::read::XzDecoder::new(reader)),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
fixups,
|
||||
)
|
||||
} else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") {
|
||||
extract_with_progress(
|
||||
tar::Archive::new(zstd::stream::read::Decoder::new(reader)?),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
fixups,
|
||||
)
|
||||
} else {
|
||||
// Fall back to magic-byte detection
|
||||
@@ -178,6 +188,7 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
|
||||
)?))),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
fixups,
|
||||
),
|
||||
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_with_progress(
|
||||
tar::Archive::new(xz2::read::XzDecoder::new(BufReader::new(File::open(
|
||||
@@ -185,6 +196,7 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
|
||||
)?))),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
fixups,
|
||||
),
|
||||
[0x28, 0xb5, 0x2f, 0xfd, ..] => extract_with_progress(
|
||||
tar::Archive::new(zstd::stream::read::Decoder::new(BufReader::new(
|
||||
@@ -192,56 +204,152 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
|
||||
))?),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
fixups,
|
||||
),
|
||||
_ => extract_with_progress(
|
||||
tar::Archive::new(BufReader::new(File::open(layer_path)?)),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
fixups,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_gz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
fn extract_gz<R: std::io::Read>(reader: R, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
|
||||
let gz_decoder = flate2::read::GzDecoder::new(reader);
|
||||
let archive = tar::Archive::new(gz_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting gzip archive")?;
|
||||
extract_with_progress(archive, dest, "Extracting gzip archive", fixups)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_xz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
fn extract_xz<R: std::io::Read>(reader: R, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
|
||||
let xz_decoder = xz2::read::XzDecoder::new(reader);
|
||||
let archive = tar::Archive::new(xz_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting xz archive")?;
|
||||
extract_with_progress(archive, dest, "Extracting xz archive", fixups)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_zst<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
fn extract_zst<R: std::io::Read>(
|
||||
reader: R,
|
||||
dest: &Path,
|
||||
fixups: &mut DeferredFixups,
|
||||
) -> Result<()> {
|
||||
let zst_decoder = zstd::Decoder::new(reader)?;
|
||||
let archive = tar::Archive::new(zst_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting zstd archive")?;
|
||||
extract_with_progress(archive, dest, "Extracting zstd archive", fixups)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
fn extract_tar<R: std::io::Read>(
|
||||
reader: R,
|
||||
dest: &Path,
|
||||
fixups: &mut DeferredFixups,
|
||||
) -> Result<()> {
|
||||
let archive = tar::Archive::new(reader);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting tar archive")?;
|
||||
extract_with_progress(archive, dest, "Extracting tar archive", fixups)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Archive entries that must not be materialized inline because the tar
|
||||
/// order does not guarantee their prerequisites exist yet.
|
||||
#[derive(Default)]
|
||||
struct DeferredFixups {
|
||||
/// Directory entries with the mode they ship; applied last. Images
|
||||
/// routinely ship read-only directories (Fedora's ca-trust tree is
|
||||
/// dr-xr-xr-x) whose contents would be rejected by an eagerly-applied
|
||||
/// mode.
|
||||
dir_modes: Vec<(PathBuf, u32)>,
|
||||
/// Hard link entries as (destination, raw link target); applied after
|
||||
/// the file pass so targets appearing later in the tar (or in an
|
||||
/// earlier layer) resolve.
|
||||
hard_links: Vec<(PathBuf, PathBuf)>,
|
||||
}
|
||||
|
||||
/// Join an archive-relative entry path onto `dest`, refusing paths that
|
||||
/// traverse above the root (mirrors tar's own unpack component walk).
|
||||
fn safe_join(dest: &Path, rel: &Path) -> Option<PathBuf> {
|
||||
let mut out = dest.to_path_buf();
|
||||
for part in rel.components() {
|
||||
match part {
|
||||
Component::Prefix(_) | Component::RootDir | Component::CurDir => {}
|
||||
Component::ParentDir => return None,
|
||||
Component::Normal(p) => out.push(p),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Resolve a raw link target against `dest`, normalizing `..` lexically.
|
||||
/// None when the target climbs out of the rootfs.
|
||||
fn normalize_within(dest: &Path, target: &Path) -> Option<PathBuf> {
|
||||
let mut out = dest.to_path_buf();
|
||||
for part in target.components() {
|
||||
match part {
|
||||
Component::Prefix(_) | Component::RootDir | Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if out == dest {
|
||||
return None;
|
||||
}
|
||||
out.pop();
|
||||
}
|
||||
Component::Normal(p) => out.push(p),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Apply hard links and directory modes collected during extraction.
|
||||
fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
|
||||
for (dst, target) in fixups.hard_links.drain(..) {
|
||||
apply_hard_link(dest, &dst, &target)?;
|
||||
}
|
||||
for (path, mode) in fixups.dir_modes.drain(..) {
|
||||
// Errors are ignored: a later layer's whiteout may have removed the
|
||||
// directory, and the mode is cosmetic for our chroot use.
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Materialize one hard link. A real hard link is used when the target
|
||||
/// exists on disk; otherwise the entry is preserved verbatim as a symlink —
|
||||
/// some images (Fedora's ca-trust directory-hash) ship links whose targets
|
||||
/// are generated after linking and never exist anywhere in the archive.
|
||||
fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> Result<()> {
|
||||
if dst.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(dst)
|
||||
.with_context(|| format!("Failed to replace {}", dst.display()))?;
|
||||
}
|
||||
if let Some(src) = normalize_within(dest, target) {
|
||||
if std::fs::hard_link(&src, dst).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
std::os::unix::fs::symlink(target, dst)
|
||||
.with_context(|| format!("Failed to link {} -> {}", dst.display(), target.display()))?;
|
||||
veprintln!(
|
||||
"Warning: hard link target {} absent; extracted {} as a symlink",
|
||||
target.display(),
|
||||
dst.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract tar archive with progress bar, handling whiteout files for OCI layers
|
||||
fn extract_with_progress<R: std::io::Read>(
|
||||
mut archive: tar::Archive<R>,
|
||||
dest: &Path,
|
||||
msg: &str,
|
||||
fixups: &mut DeferredFixups,
|
||||
) -> Result<()> {
|
||||
let pb = ProgressBar::new_spinner();
|
||||
pb.set_style(
|
||||
@@ -296,6 +404,27 @@ fn extract_with_progress<R: std::io::Read>(
|
||||
.with_context(|| format!("Whiteout: failed to remove {}", target.display()))?;
|
||||
}
|
||||
// Do not extract the .wh.* marker itself
|
||||
} else if entry.header().entry_type().is_dir() {
|
||||
// Create the directory but defer its mode: the mode in the tar
|
||||
// may be read-only, and this directory's own contents (and other
|
||||
// layers') still need to be written into it.
|
||||
if let Some(dest_dir) = safe_join(dest, &path) {
|
||||
std::fs::create_dir_all(&dest_dir)
|
||||
.with_context(|| format!("Failed to create {}", dest_dir.display()))?;
|
||||
if let Ok(mode) = entry.header().mode() {
|
||||
fixups.dir_modes.push((dest_dir, mode));
|
||||
}
|
||||
}
|
||||
} else if entry.header().entry_type().is_hard_link() {
|
||||
// Defer: the target may be a later entry in this tar, live in an
|
||||
// earlier layer, or not exist at all (see apply_hard_link).
|
||||
if let Some(dest_path) = safe_join(dest, &path) {
|
||||
if let Some(link) = entry.link_name().context("Invalid hard link target")? {
|
||||
if !link.as_os_str().is_empty() {
|
||||
fixups.hard_links.push((dest_path, link.into_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entry
|
||||
.unpack_in(dest)
|
||||
@@ -322,3 +451,184 @@ fn remove_path(path: &Path) -> std::io::Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
use tar::{Builder, EntryType, Header};
|
||||
|
||||
/// Build a plain tar archive from (path, entry-type, mode, payload)
|
||||
/// tuples in the given order; link payloads are the raw link targets.
|
||||
fn build_tar(path: &Path, entries: &[(&str, EntryType, u32, &[u8])]) {
|
||||
let file = File::create(path).unwrap();
|
||||
let mut ar = Builder::new(file);
|
||||
for (name, kind, mode, payload) in entries {
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_size(if *kind == EntryType::Regular {
|
||||
payload.len() as u64
|
||||
} else {
|
||||
0
|
||||
});
|
||||
header.set_entry_type(*kind);
|
||||
header.set_mode(*mode);
|
||||
if *kind == EntryType::Symlink || *kind == EntryType::Link {
|
||||
header
|
||||
.set_link_name(std::path::PathBuf::from(
|
||||
std::str::from_utf8(payload).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
header.set_cksum();
|
||||
if *kind == EntryType::Regular {
|
||||
ar.append_data(&mut header, name, *payload).unwrap();
|
||||
} else {
|
||||
ar.append_data(&mut header, name, std::io::empty()).unwrap();
|
||||
}
|
||||
}
|
||||
ar.finish().unwrap();
|
||||
}
|
||||
|
||||
/// Fedora's ca-certificates ships etc/pki/ca-trust/extracted/pem/
|
||||
/// directory-hash/ as a read-only (0555) directory full of per-cert hash
|
||||
/// symlinks (002c0b4f.0 -> GlobalSign_Root_R46.pem) whose targets come
|
||||
/// later in the tar or not at all, plus hard links elsewhere in the
|
||||
/// image. Extracting must never open or follow a link payload, and must
|
||||
/// not choke on the read-only directory.
|
||||
#[test]
|
||||
fn fedora_directory_hash_links_extract_without_following() {
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let tarball = src.path().join("rootfs.tar");
|
||||
build_tar(
|
||||
&tarball,
|
||||
&[
|
||||
(
|
||||
"etc/pki/ca-trust/extracted/pem/directory-hash/",
|
||||
EntryType::Directory,
|
||||
0o555,
|
||||
b"",
|
||||
),
|
||||
// symlink whose target appears later in the archive
|
||||
(
|
||||
"etc/pki/ca-trust/extracted/pem/directory-hash/002c0b4f.0",
|
||||
EntryType::Symlink,
|
||||
0o777,
|
||||
b"GlobalSign_Root_R46.pem",
|
||||
),
|
||||
// symlink whose target never appears (dangling)
|
||||
(
|
||||
"etc/pki/ca-trust/extracted/pem/directory-hash/01419da9.0",
|
||||
EntryType::Symlink,
|
||||
0o777,
|
||||
b"Missing_Root.pem",
|
||||
),
|
||||
// the real cert file, written after the read-only dir exists
|
||||
(
|
||||
"etc/pki/ca-trust/extracted/pem/directory-hash/GlobalSign_Root_R46.pem",
|
||||
EntryType::Regular,
|
||||
0o644,
|
||||
b"-----BEGIN CERTIFICATE-----",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
extract_tarball(&tarball, out.path()).unwrap();
|
||||
|
||||
let base = out
|
||||
.path()
|
||||
.join("etc/pki/ca-trust/extracted/pem/directory-hash");
|
||||
assert_eq!(
|
||||
std::fs::read_link(base.join("002c0b4f.0")).unwrap(),
|
||||
Path::new("GlobalSign_Root_R46.pem")
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_link(base.join("01419da9.0")).unwrap(),
|
||||
Path::new("Missing_Root.pem"),
|
||||
"dangling symlinks must be created verbatim, not followed"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(base.join("GlobalSign_Root_R46.pem")).unwrap(),
|
||||
b"-----BEGIN CERTIFICATE-----"
|
||||
);
|
||||
let dir_mode = base.symlink_metadata().unwrap().permissions().mode() & 0o7777;
|
||||
assert_eq!(dir_mode, 0o555, "shipped directory mode must be restored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_link_to_later_target_extracts() {
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let tarball = src.path().join("rootfs.tar");
|
||||
build_tar(
|
||||
&tarball,
|
||||
&[
|
||||
("etc/dir/", EntryType::Directory, 0o755, b""),
|
||||
// hard link recorded before its target
|
||||
(
|
||||
"etc/dir/link.0",
|
||||
EntryType::Link,
|
||||
0o644,
|
||||
b"etc/dir/real.pem",
|
||||
),
|
||||
("etc/dir/real.pem", EntryType::Regular, 0o644, b"payload"),
|
||||
],
|
||||
);
|
||||
|
||||
extract_tarball(&tarball, out.path()).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(out.path().join("etc/dir/link.0")).unwrap(),
|
||||
b"payload"
|
||||
);
|
||||
let link = out
|
||||
.path()
|
||||
.join("etc/dir/link.0")
|
||||
.symlink_metadata()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!link.file_type().is_symlink(),
|
||||
"target exists later in the tar, so a real hard link is expected"
|
||||
);
|
||||
let link_ino = std::fs::metadata(out.path().join("etc/dir/link.0"))
|
||||
.unwrap()
|
||||
.ino();
|
||||
let real_ino = std::fs::metadata(out.path().join("etc/dir/real.pem"))
|
||||
.unwrap()
|
||||
.ino();
|
||||
assert_eq!(link_ino, real_ino);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_hard_link_does_not_abort_extraction() {
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let tarball = src.path().join("rootfs.tar");
|
||||
build_tar(
|
||||
&tarball,
|
||||
&[
|
||||
("etc/dir/", EntryType::Directory, 0o755, b""),
|
||||
// target is not in the archive at all
|
||||
(
|
||||
"etc/dir/dangling.0",
|
||||
EntryType::Link,
|
||||
0o644,
|
||||
b"etc/dir/absent.pem",
|
||||
),
|
||||
("etc/dir/keep.txt", EntryType::Regular, 0o644, b"kept"),
|
||||
],
|
||||
);
|
||||
|
||||
extract_tarball(&tarball, out.path()).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(out.path().join("etc/dir/keep.txt")).unwrap(),
|
||||
b"kept",
|
||||
"extraction must not abort on an unresolvable hard link"
|
||||
);
|
||||
let meta = out.path().join("etc/dir/dangling.0").symlink_metadata();
|
||||
assert!(meta.map(|m| m.file_type().is_symlink()).unwrap_or(false));
|
||||
assert_eq!(
|
||||
std::fs::read_link(out.path().join("etc/dir/dangling.0")).unwrap(),
|
||||
Path::new("etc/dir/absent.pem")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user