diff --git a/cli/Cargo.toml b/cli/Cargo.toml index e54f73be3..761b92f6d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -40,6 +40,7 @@ path-slash = "0.2.1" pna = { version = "0.30.0", path = "../pna" } rayon = "1.11.0" regex = "1.12.2" +scopeguard = "1.2.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" tabled = { version = "0.20.0", default-features = false, features = ["std", "ansi"] } @@ -57,7 +58,6 @@ exacl = { version = "0.12.0", optional = true } nix = { version = "0.30.1", features = ["user", "fs", "ioctl"] } [target.'cfg(windows)'.dependencies] -scopeguard = "1.2.0" windows = { version = "0.62.2", features = [ "Win32_Storage_FileSystem", "Win32_Security_Authorization", diff --git a/cli/src/chunk.rs b/cli/src/chunk.rs index 998a4dfbc..1f624d657 100644 --- a/cli/src/chunk.rs +++ b/cli/src/chunk.rs @@ -1,5 +1,7 @@ mod acl; mod fflag; +mod mac_metadata; pub use acl::*; pub use fflag::*; +pub use mac_metadata::*; diff --git a/cli/src/chunk/mac_metadata.rs b/cli/src/chunk/mac_metadata.rs new file mode 100644 index 000000000..fe86fe8d8 --- /dev/null +++ b/cli/src/chunk/mac_metadata.rs @@ -0,0 +1,10 @@ +use pna::ChunkType; + +/// Private chunk type for macOS metadata (AppleDouble format). +/// Name follows PNA chunk naming convention where case has semantic meaning: +/// - lowercase first letter: ancillary (not critical) +/// - lowercase second letter: private (not public) +/// - uppercase third letter: reserved +/// - lowercase fourth letter: safe to copy +#[allow(non_upper_case_globals)] +pub const maMd: ChunkType = unsafe { ChunkType::from_unchecked(*b"maMd") }; diff --git a/cli/src/command/append.rs b/cli/src/command/append.rs index d543028b6..793a3db51 100644 --- a/cli/src/command/append.rs +++ b/cli/src/command/append.rs @@ -7,9 +7,9 @@ use crate::{ Command, ask_password, check_password, core::{ AclStrategy, CollectOptions, CollectedItem, CreateOptions, FflagsStrategy, KeepOptions, - OwnerOptions, PathFilter, PathTransformers, PathnameEditor, PermissionStrategy, - TimeFilterResolver, TimestampStrategyResolver, XattrStrategy, collect_items_from_paths, - create_entry, entry_option, + MacMetadataStrategy, OwnerOptions, PathFilter, PathTransformers, PathnameEditor, + PermissionStrategy, TimeFilterResolver, TimestampStrategyResolver, XattrStrategy, + collect_items_from_paths, create_entry, entry_option, re::{bsd::SubstitutionRule, gnu::TransformRule}, read_paths, read_paths_stdin, }, @@ -392,6 +392,7 @@ fn append_to_archive(args: AppendCommand) -> anyhow::Result<()> { xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr), acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl), fflags_strategy: FflagsStrategy::Never, + mac_metadata_strategy: MacMetadataStrategy::Never, }; let owner_options = OwnerOptions::new( args.uname, diff --git a/cli/src/command/core.rs b/cli/src/command/core.rs index 94df99b12..19d998e29 100644 --- a/cli/src/command/core.rs +++ b/cli/src/command/core.rs @@ -179,6 +179,38 @@ impl FflagsStrategy { } } +/// Strategy for handling macOS metadata in AppleDouble format. +/// When enabled, creates `._` prefixed entries containing AppleDouble data +/// (extended attributes, ACLs, resource forks) for each file with Mac metadata. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum MacMetadataStrategy { + /// Do not create AppleDouble entries (default) + #[default] + Never, + /// Create AppleDouble entries for files with Mac metadata (macOS only) + Always, +} + +impl MacMetadataStrategy { + /// Creates a strategy from CLI flags, considering platform. + /// On non-macOS platforms, always returns Never regardless of flags. + #[cfg(target_os = "macos")] + pub(crate) const fn from_flags(mac_metadata: bool, no_mac_metadata: bool) -> Self { + if no_mac_metadata { + Self::Never + } else if mac_metadata { + Self::Always + } else { + Self::Never + } + } + + #[cfg(not(target_os = "macos"))] + pub(crate) const fn from_flags(_mac_metadata: bool, _no_mac_metadata: bool) -> Self { + Self::Never + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub(crate) struct KeepOptions { pub(crate) timestamp_strategy: TimestampStrategy, @@ -186,6 +218,7 @@ pub(crate) struct KeepOptions { pub(crate) xattr_strategy: XattrStrategy, pub(crate) acl_strategy: AclStrategy, pub(crate) fflags_strategy: FflagsStrategy, + pub(crate) mac_metadata_strategy: MacMetadataStrategy, } /// Resolves CLI timestamp options into a `TimestampStrategy`. @@ -735,8 +768,18 @@ pub(crate) fn apply_metadata( mode, )); } + // On macOS, when mac_metadata_strategy is Always, AppleDouble packing via copyfile() + // already includes xattrs and ACLs. Skip separate handling to avoid duplication. + #[cfg(target_os = "macos")] + let skip_xattr_acl = matches!( + keep_options.mac_metadata_strategy, + MacMetadataStrategy::Always + ); + #[cfg(not(target_os = "macos"))] + let skip_xattr_acl = false; + #[cfg(feature = "acl")] - { + if !skip_xattr_acl { #[cfg(any( target_os = "linux", target_os = "freebsd", @@ -767,21 +810,23 @@ pub(crate) fn apply_metadata( log::warn!("Please enable `acl` feature and rebuild and install pna."); } #[cfg(unix)] - if let XattrStrategy::Always = keep_options.xattr_strategy { - match utils::os::unix::fs::xattrs::get_xattrs(path) { - Ok(xattrs) => { - for attr in xattrs { - entry.add_xattr(attr); + if !skip_xattr_acl { + if let XattrStrategy::Always = keep_options.xattr_strategy { + match utils::os::unix::fs::xattrs::get_xattrs(path) { + Ok(xattrs) => { + for attr in xattrs { + entry.add_xattr(attr); + } } + Err(e) if e.kind() == std::io::ErrorKind::Unsupported => { + log::warn!( + "Extended attributes are not supported on filesystem for '{}': {}", + path.display(), + e + ); + } + Err(e) => return Err(e), } - Err(e) if e.kind() == std::io::ErrorKind::Unsupported => { - log::warn!( - "Extended attributes are not supported on filesystem for '{}': {}", - path.display(), - e - ); - } - Err(e) => return Err(e), } } #[cfg(not(unix))] @@ -805,6 +850,40 @@ pub(crate) fn apply_metadata( Err(e) => return Err(e), } } + // macOS metadata (AppleDouble) - packs xattrs, ACLs, resource forks via copyfile() + #[cfg(target_os = "macos")] + if let MacMetadataStrategy::Always = keep_options.mac_metadata_strategy { + use pna::RawChunk; + match utils::os::unix::fs::copyfile::pack_apple_double(path) { + Ok(apple_double_data) => { + if !apple_double_data.is_empty() { + let len = apple_double_data.len(); + entry.add_extra_chunk(RawChunk::from_data( + crate::chunk::maMd, + apple_double_data, + )); + log::debug!( + "Packed macOS metadata for '{}' ({len} bytes)", + path.display(), + ); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // File has no Mac metadata, this is fine + } + Err(e) => { + log::warn!( + "Failed to pack macOS metadata for '{}': {}", + path.display(), + e + ); + } + } + } + #[cfg(not(target_os = "macos"))] + if let MacMetadataStrategy::Always = keep_options.mac_metadata_strategy { + log::warn!("macOS metadata (--mac-metadata) is only supported on macOS."); + } Ok(entry) } diff --git a/cli/src/command/create.rs b/cli/src/command/create.rs index 47ef25876..1e4009be3 100644 --- a/cli/src/command/create.rs +++ b/cli/src/command/create.rs @@ -7,9 +7,9 @@ use crate::{ Command, ask_password, check_password, core::{ AclStrategy, CollectOptions, CollectedItem, CreateOptions, FflagsStrategy, KeepOptions, - MIN_SPLIT_PART_BYTES, OwnerOptions, PathFilter, PathTransformers, PathnameEditor, - PermissionStrategy, TimeFilterResolver, TimestampStrategyResolver, XattrStrategy, - collect_items_from_paths, create_entry, entry_option, + MIN_SPLIT_PART_BYTES, MacMetadataStrategy, OwnerOptions, PathFilter, PathTransformers, + PathnameEditor, PermissionStrategy, TimeFilterResolver, TimestampStrategyResolver, + XattrStrategy, collect_items_from_paths, create_entry, entry_option, re::{bsd::SubstitutionRule, gnu::TransformRule}, read_paths, read_paths_stdin, write_split_archive, }, @@ -478,6 +478,7 @@ fn create_archive(args: CreateCommand) -> anyhow::Result<()> { xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr), acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl), fflags_strategy: FflagsStrategy::Never, + mac_metadata_strategy: MacMetadataStrategy::Never, }; let owner_options = OwnerOptions::new( args.uname, diff --git a/cli/src/command/extract.rs b/cli/src/command/extract.rs index 86355227f..13dab46fe 100644 --- a/cli/src/command/extract.rs +++ b/cli/src/command/extract.rs @@ -8,9 +8,10 @@ use crate::{ command::{ Command, ask_password, core::{ - AclStrategy, FflagsStrategy, KeepOptions, OwnerOptions, PathFilter, PathTransformers, - PathnameEditor, PermissionStrategy, TimeFilterResolver, TimeFilters, TimestampStrategy, - TimestampStrategyResolver, XattrStrategy, apply_chroot, collect_split_archives, + AclStrategy, FflagsStrategy, KeepOptions, MacMetadataStrategy, OwnerOptions, + PathFilter, PathTransformers, PathnameEditor, PermissionStrategy, TimeFilterResolver, + TimeFilters, TimestampStrategy, TimestampStrategyResolver, XattrStrategy, apply_chroot, + collect_split_archives, path_lock::PathLocks, re::{bsd::SubstitutionRule, gnu::TransformRule}, read_paths, run_process_archive, @@ -420,6 +421,7 @@ fn extract_archive(args: ExtractCommand) -> anyhow::Result<()> { xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr), acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl), fflags_strategy: FflagsStrategy::Never, + mac_metadata_strategy: MacMetadataStrategy::Never, }; let owner_options = OwnerOptions::new( args.uname, @@ -872,9 +874,9 @@ fn restore_timestamps( Ok(()) } -/// Restores file metadata (permissions, extended attributes, and ACLs) for an extracted entry according to the provided keep and owner options. +/// Restores file metadata (permissions, extended attributes, ACLs, and macOS metadata) for an extracted entry according to the provided keep and owner options. /// -/// Permissions are applied when `keep_options.permission_strategy` is `Always`. Extended attributes are applied on Unix when `keep_options.xattr_strategy` is `Always` (logs a warning if the filesystem or platform does not support xattrs). ACLs are restored when the `acl` feature is enabled and `keep_options.acl_strategy` requests them; if the `acl` feature is not compiled in but ACLs were requested, a warning is emitted. +/// Permissions are applied when `keep_options.permission_strategy` is `Always`. Extended attributes are applied on Unix when `keep_options.xattr_strategy` is `Always` (logs a warning if the filesystem or platform does not support xattrs). ACLs are restored when the `acl` feature is enabled and `keep_options.acl_strategy` requests them; if the `acl` feature is not compiled in but ACLs were requested, a warning is emitted. macOS metadata (AppleDouble) is restored when `keep_options.mac_metadata_strategy` is `Always` on macOS. fn restore_metadata( item: &NormalEntry, path: &Path, @@ -890,18 +892,31 @@ where restore_permissions(*same_owner, path, p, owner_options)?; } } + // On macOS, when mac_metadata_strategy is Always and the entry has mac_metadata, + // AppleDouble restoration via copyfile() will include xattrs and ACLs. + // Skip separate handling to avoid duplication. + #[cfg(target_os = "macos")] + let skip_xattr_acl = matches!( + keep_options.mac_metadata_strategy, + MacMetadataStrategy::Always + ) && item.mac_metadata().is_some(); + #[cfg(not(target_os = "macos"))] + let skip_xattr_acl = false; + #[cfg(unix)] - if let XattrStrategy::Always = keep_options.xattr_strategy { - match utils::os::unix::fs::xattrs::set_xattrs(path, item.xattrs()) { - Ok(()) => {} - Err(e) if e.kind() == io::ErrorKind::Unsupported => { - log::warn!( - "Extended attributes are not supported on filesystem for '{}': {}", - path.display(), - e - ); + if !skip_xattr_acl { + if let XattrStrategy::Always = keep_options.xattr_strategy { + match utils::os::unix::fs::xattrs::set_xattrs(path, item.xattrs()) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::Unsupported => { + log::warn!( + "Extended attributes are not supported on filesystem for '{}': {}", + path.display(), + e + ); + } + Err(e) => return Err(e), } - Err(e) => return Err(e), } } #[cfg(not(unix))] @@ -909,7 +924,9 @@ where log::warn!("Currently extended attribute is not supported on this platform."); } #[cfg(feature = "acl")] - restore_acls(path, item.acl()?, keep_options.acl_strategy)?; + if !skip_xattr_acl { + restore_acls(path, item.acl()?, keep_options.acl_strategy)?; + } #[cfg(not(feature = "acl"))] if let AclStrategy::Always = keep_options.acl_strategy { log::warn!("Please enable `acl` feature and rebuild and install pna."); @@ -930,6 +947,33 @@ where } } } + // macOS metadata (AppleDouble) - restores xattrs, ACLs, resource forks via copyfile() + #[cfg(target_os = "macos")] + if let MacMetadataStrategy::Always = keep_options.mac_metadata_strategy { + if let Some(apple_double_data) = item.mac_metadata() { + match utils::os::unix::fs::copyfile::unpack_apple_double(apple_double_data, path) { + Ok(()) => { + log::debug!("Unpacked macOS metadata for '{}'", path.display()); + } + Err(e) => { + log::warn!( + "Failed to restore macOS metadata for '{}': {}", + path.display(), + e + ); + } + } + } + } + #[cfg(not(target_os = "macos"))] + if let MacMetadataStrategy::Always = keep_options.mac_metadata_strategy { + if item.mac_metadata().is_some() { + log::warn!( + "macOS metadata present but cannot be restored on this platform: '{}'", + path.display() + ); + } + } Ok(()) } diff --git a/cli/src/command/stdio.rs b/cli/src/command/stdio.rs index 24091f415..a9558ec09 100644 --- a/cli/src/command/stdio.rs +++ b/cli/src/command/stdio.rs @@ -8,10 +8,11 @@ use crate::{ append::{open_archive_then_seek_to_end, run_append_archive}, ask_password, check_password, core::{ - AclStrategy, CollectOptions, CreateOptions, FflagsStrategy, KeepOptions, OwnerOptions, - PathFilter, PathTransformers, PathnameEditor, PermissionStrategy, TimeFilterResolver, - TimestampStrategyResolver, TransformStrategyUnSolid, XattrStrategy, apply_chroot, - collect_items_from_paths, collect_split_archives, entry_option, + AclStrategy, CollectOptions, CreateOptions, FflagsStrategy, KeepOptions, + MacMetadataStrategy, OwnerOptions, PathFilter, PathTransformers, PathnameEditor, + PermissionStrategy, TimeFilterResolver, TimestampStrategyResolver, + TransformStrategyUnSolid, XattrStrategy, apply_chroot, collect_items_from_paths, + collect_split_archives, entry_option, path_lock::PathLocks, re::{bsd::SubstitutionRule, gnu::TransformRule}, read_paths, @@ -61,6 +62,7 @@ use std::{env, io, path::PathBuf, sync::Arc, time::SystemTime}; group(ArgGroup::new("mtime-older-than-source").args(["older_mtime", "older_mtime_than"])), group(ArgGroup::new("mtime-newer-than-source").args(["newer_mtime", "newer_mtime_than"])), group(ArgGroup::new("keep-fflags-flag").args(["keep_fflags", "no_keep_fflags"])), + group(ArgGroup::new("mac-metadata-flag").args(["mac_metadata", "no_mac_metadata"])), )] #[cfg_attr(windows, command( group(ArgGroup::new("windows-unstable-keep-permission").args(["keep_permission", "no_keep_permission"]).requires("unstable")), @@ -202,6 +204,18 @@ pub(crate) struct StdioCommand { help = "Do not archive file flags of files. This is the inverse option of --keep-fflags (unstable)" )] no_keep_fflags: bool, + #[arg( + long, + requires = "unstable", + help = "Archive and extract Mac metadata (extended attributes and ACLs) (unstable)" + )] + mac_metadata: bool, + #[arg( + long, + requires = "unstable", + help = "Do not archive or extract Mac metadata. This is the inverse option of --mac-metadata (unstable)" + )] + no_mac_metadata: bool, #[arg( long, help = "Compress multiple files together for better compression ratio" @@ -654,6 +668,10 @@ fn run_create_archive(args: StdioCommand) -> anyhow::Result<()> { xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr), acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl), fflags_strategy: FflagsStrategy::from_flags(args.keep_fflags, args.no_keep_fflags), + mac_metadata_strategy: MacMetadataStrategy::from_flags( + args.mac_metadata, + args.no_mac_metadata, + ), }; let owner_options = resolve_owner_options( args.owner, @@ -748,6 +766,10 @@ fn run_extract_archive(args: StdioCommand) -> anyhow::Result<()> { xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr), acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl), fflags_strategy: FflagsStrategy::from_flags(args.keep_fflags, args.no_keep_fflags), + mac_metadata_strategy: MacMetadataStrategy::from_flags( + args.mac_metadata, + args.no_mac_metadata, + ), }, owner_options: resolve_owner_options( args.owner, @@ -907,6 +929,10 @@ fn run_append(args: StdioCommand) -> anyhow::Result<()> { xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr), acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl), fflags_strategy: FflagsStrategy::from_flags(args.keep_fflags, args.no_keep_fflags), + mac_metadata_strategy: MacMetadataStrategy::from_flags( + args.mac_metadata, + args.no_mac_metadata, + ), }; let owner_options = resolve_owner_options( args.owner, @@ -1044,6 +1070,10 @@ fn run_update(args: StdioCommand) -> anyhow::Result<()> { xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr), acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl), fflags_strategy: FflagsStrategy::from_flags(args.keep_fflags, args.no_keep_fflags), + mac_metadata_strategy: MacMetadataStrategy::from_flags( + args.mac_metadata, + args.no_mac_metadata, + ), }; let owner_options = OwnerOptions::new( args.uname, diff --git a/cli/src/command/update.rs b/cli/src/command/update.rs index 4a34cb9fe..9c1162c7f 100644 --- a/cli/src/command/update.rs +++ b/cli/src/command/update.rs @@ -11,8 +11,8 @@ use crate::{ Command, ask_password, check_password, core::{ AclStrategy, CollectOptions, CollectedItem, CreateOptions, FflagsStrategy, KeepOptions, - OwnerOptions, PathFilter, PathTransformers, PathnameEditor, PermissionStrategy, - TimeFilterResolver, TimestampStrategyResolver, TransformStrategy, + MacMetadataStrategy, OwnerOptions, PathFilter, PathTransformers, PathnameEditor, + PermissionStrategy, TimeFilterResolver, TimestampStrategyResolver, TransformStrategy, TransformStrategyKeepSolid, TransformStrategyUnSolid, XattrStrategy, collect_items_from_paths, collect_split_archives, create_entry, entry_option, re::{bsd::SubstitutionRule, gnu::TransformRule}, @@ -401,6 +401,7 @@ fn update_archive(args: UpdateCommand) -> anyhow::Result<()> { xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr), acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl), fflags_strategy: FflagsStrategy::Never, + mac_metadata_strategy: MacMetadataStrategy::Never, }; let owner_options = OwnerOptions::new( args.uname, diff --git a/cli/src/ext.rs b/cli/src/ext.rs index 475cd7f79..a82797873 100644 --- a/cli/src/ext.rs +++ b/cli/src/ext.rs @@ -14,6 +14,8 @@ pub(crate) type Acls = HashMap>; pub(crate) trait NormalEntryExt { fn acl(&self) -> io::Result; fn fflags(&self) -> Vec; + /// Returns the macOS metadata (AppleDouble blob) if present. + fn mac_metadata(&self) -> Option<&[u8]>; } impl NormalEntryExt for NormalEntry @@ -58,6 +60,14 @@ where }) .collect() } + + #[inline] + fn mac_metadata(&self) -> Option<&[u8]> { + self.extra_chunks() + .iter() + .find(|c| c.ty() == chunk::maMd) + .map(|c| c.data()) + } } pub(crate) trait PermissionExt { diff --git a/cli/src/utils/os/unix/fs.rs b/cli/src/utils/os/unix/fs.rs index ca98db199..01a048166 100644 --- a/cli/src/utils/os/unix/fs.rs +++ b/cli/src/utils/os/unix/fs.rs @@ -86,6 +86,90 @@ pub(crate) fn set_flags(path: &Path, flags: &[String]) -> io::Result<()> { Ok(()) } +/// macOS copyfile() API for AppleDouble format handling. +/// Reference: https://keith.github.io/xcode-man-pages/copyfile.3.html +#[cfg(target_os = "macos")] +pub(crate) mod copyfile { + use libc::{ + COPYFILE_ACL, COPYFILE_NOFOLLOW, COPYFILE_PACK, COPYFILE_UNPACK, COPYFILE_XATTR, + copyfile_flags_t, copyfile_state_t, + }; + use std::{ + fs, io, + io::prelude::*, + path::Path, + sync::atomic::{AtomicU64, Ordering}, + }; + + /// Counter for generating unique temporary file names. + static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn copyfile( + from: &P, + to: &Q, + state: copyfile_state_t, + flags: copyfile_flags_t, + ) -> nix::Result<()> { + let res = from.with_nix_path(|from| { + to.with_nix_path(|to| unsafe { + libc::copyfile(from.as_ptr(), to.as_ptr(), state, flags) + }) + })??; + nix::errno::Errno::result(res)?; + Ok(()) + } + + /// Packs Mac metadata into AppleDouble format. + /// Returns the AppleDouble data as a byte vector. + pub fn pack_apple_double(source_path: &Path) -> io::Result> { + // Create a temporary file for the AppleDouble output + let temp_dir = std::env::temp_dir(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let temp_file = temp_dir.join(format!("pna_appledouble_{}_{counter}", std::process::id())); + let clean = scopeguard::guard(&temp_file, |path| { + let _ = fs::remove_file(path); + }); + + // Pack metadata into AppleDouble format + copyfile( + source_path, + &temp_file, + std::ptr::null_mut(), + COPYFILE_NOFOLLOW | COPYFILE_ACL | COPYFILE_XATTR | COPYFILE_PACK, + )?; + + // Read the AppleDouble data + let data = fs::read(&temp_file)?; + drop(clean); + Ok(data) + } + + /// Unpacks AppleDouble data and applies metadata to the target file. + pub fn unpack_apple_double(apple_double_data: &[u8], target_path: &Path) -> io::Result<()> { + // Write AppleDouble data to a temporary file + let temp_dir = std::env::temp_dir(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let temp_file = temp_dir.join(format!("pna_appledouble_{}_{counter}", std::process::id())); + let clean = scopeguard::guard(&temp_file, |path| { + let _ = fs::remove_file(path); + }); + { + let mut file = fs::File::create(&temp_file)?; + file.write_all(apple_double_data)?; + } + + // Unpack AppleDouble format to target + copyfile( + &temp_file, + target_path, + std::ptr::null_mut(), + COPYFILE_NOFOLLOW | COPYFILE_ACL | COPYFILE_XATTR | COPYFILE_UNPACK, + )?; + drop(clean); + Ok(()) + } +} + // Linux file flags (FS_IOC_GETFLAGS/FS_IOC_SETFLAGS) // Reference: https://man7.org/linux/man-pages/man2/ioctl_iflags.2.html #[cfg(any(target_os = "linux", target_os = "android"))] diff --git a/cli/tests/cli/stdio.rs b/cli/tests/cli/stdio.rs index 1c469fd4b..0d77b1f1b 100644 --- a/cli/tests/cli/stdio.rs +++ b/cli/tests/cli/stdio.rs @@ -3,6 +3,7 @@ mod files_from; mod option_auto_compress; mod option_block_size; mod option_check_links; +mod option_mac_metadata; mod option_no_recursive; mod option_update; mod strip_components; diff --git a/cli/tests/cli/stdio/option_mac_metadata.rs b/cli/tests/cli/stdio/option_mac_metadata.rs new file mode 100644 index 000000000..f759e3892 --- /dev/null +++ b/cli/tests/cli/stdio/option_mac_metadata.rs @@ -0,0 +1,356 @@ +#![cfg(not(target_family = "wasm"))] + +use crate::utils::setup; +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::prelude::predicate; +use std::fs; + +/// Precondition: A file exists in the filesystem. +/// Action: Create archive using stdio with --mac-metadata --unstable flags. +/// Expectation: Command succeeds (option is recognized and accepted). +#[test] +fn stdio_mac_metadata_option_accepted() { + setup(); + let file = "stdio_mac_metadata_option_accepted.txt"; + fs::write(file, "test content").unwrap(); + + let mut cmd = cargo_bin_cmd!("pna"); + cmd.arg("experimental") + .arg("stdio") + .arg("-c") + .arg("--unstable") + .arg("--mac-metadata") + .arg(file) + .assert() + .success(); +} + +/// Precondition: A file exists in the filesystem. +/// Action: Create archive using stdio with --no-mac-metadata --unstable flags. +/// Expectation: Command succeeds (option is recognized and accepted). +#[test] +fn stdio_no_mac_metadata_option_accepted() { + setup(); + let file = "stdio_no_mac_metadata_option_accepted.txt"; + fs::write(file, "test content").unwrap(); + + let mut cmd = cargo_bin_cmd!("pna"); + cmd.arg("experimental") + .arg("stdio") + .arg("-c") + .arg("--unstable") + .arg("--no-mac-metadata") + .arg(file) + .assert() + .success(); +} + +/// Precondition: A file exists in the filesystem. +/// Action: Attempt to use --mac-metadata without --unstable flag. +/// Expectation: Command fails because --mac-metadata requires --unstable. +#[test] +fn stdio_mac_metadata_requires_unstable() { + setup(); + let file = "stdio_mac_metadata_requires_unstable.txt"; + fs::write(file, "test content").unwrap(); + + let mut cmd = cargo_bin_cmd!("pna"); + cmd.arg("experimental") + .arg("stdio") + .arg("-c") + .arg("--mac-metadata") + .arg(file) + .assert() + .failure() + .stderr(predicate::str::contains("--unstable")); +} + +/// Precondition: A file exists in the filesystem. +/// Action: Attempt to use both --mac-metadata and --no-mac-metadata together. +/// Expectation: Command fails because the options are mutually exclusive. +#[test] +fn stdio_mac_metadata_and_no_mac_metadata_mutually_exclusive() { + setup(); + let file = "stdio_mac_metadata_mutually_exclusive.txt"; + fs::write(file, "test content").unwrap(); + + let mut cmd = cargo_bin_cmd!("pna"); + cmd.arg("experimental") + .arg("stdio") + .arg("-c") + .arg("--unstable") + .arg("--mac-metadata") + .arg("--no-mac-metadata") + .arg(file) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +} + +/// Precondition: A file exists in the filesystem. +/// Action: Extract archive using stdio with --mac-metadata --unstable flags. +/// Expectation: Command succeeds (option is recognized for extract mode). +#[test] +fn stdio_extract_mac_metadata_option_accepted() { + setup(); + fs::create_dir_all("stdio_extract_mac_metadata_dir").unwrap(); + fs::write("stdio_extract_mac_metadata_dir/test.txt", "test content").unwrap(); + fs::create_dir_all("stdio_extract_mac_metadata_dir/out").unwrap(); + + // Create an archive first + cargo_bin_cmd!("pna") + .args([ + "create", + "stdio_extract_mac_metadata_dir/test.pna", + "--overwrite", + "stdio_extract_mac_metadata_dir/test.txt", + ]) + .assert() + .success(); + + // Extract with --mac-metadata + cargo_bin_cmd!("pna") + .args([ + "experimental", + "stdio", + "-x", + "--unstable", + "--mac-metadata", + "-f", + "stdio_extract_mac_metadata_dir/test.pna", + "--out-dir", + "stdio_extract_mac_metadata_dir/out", + ]) + .assert() + .success(); +} + +/// Precondition: A file exists in the filesystem. +/// Action: Use stdio append mode with --mac-metadata --unstable flags. +/// Expectation: Command succeeds (option is recognized for append mode). +#[test] +fn stdio_append_mac_metadata_option_accepted() { + setup(); + fs::create_dir_all("stdio_append_mac_metadata_dir").unwrap(); + fs::write("stdio_append_mac_metadata_dir/file1.txt", "test content 1").unwrap(); + fs::write("stdio_append_mac_metadata_dir/file2.txt", "test content 2").unwrap(); + + // Create an archive first + cargo_bin_cmd!("pna") + .args([ + "create", + "stdio_append_mac_metadata_dir/test.pna", + "--overwrite", + "stdio_append_mac_metadata_dir/file1.txt", + ]) + .assert() + .success(); + + // Append with --mac-metadata + cargo_bin_cmd!("pna") + .args([ + "experimental", + "stdio", + "-r", + "--unstable", + "--mac-metadata", + "-f", + "stdio_append_mac_metadata_dir/test.pna", + "stdio_append_mac_metadata_dir/file2.txt", + ]) + .assert() + .success(); +} + +/// Precondition: A file exists in the filesystem. +/// Action: Use stdio update mode with --mac-metadata --unstable flags. +/// Expectation: Command succeeds (option is recognized for update mode). +#[test] +fn stdio_update_mac_metadata_option_accepted() { + setup(); + fs::create_dir_all("stdio_update_mac_metadata_dir").unwrap(); + fs::write("stdio_update_mac_metadata_dir/test.txt", "test content").unwrap(); + + // Create an archive first + cargo_bin_cmd!("pna") + .args([ + "create", + "stdio_update_mac_metadata_dir/test.pna", + "--overwrite", + "stdio_update_mac_metadata_dir/test.txt", + ]) + .assert() + .success(); + + // Update the file + fs::write("stdio_update_mac_metadata_dir/test.txt", "updated content").unwrap(); + + // Update with --mac-metadata + cargo_bin_cmd!("pna") + .args([ + "experimental", + "stdio", + "-u", + "--unstable", + "--mac-metadata", + "-f", + "stdio_update_mac_metadata_dir/test.pna", + "stdio_update_mac_metadata_dir/test.txt", + ]) + .assert() + .success(); +} + +// macOS-specific tests that verify xattr preservation +#[cfg(target_os = "macos")] +mod macos_tests { + use super::*; + use std::process::Command; + + /// Precondition: A file with extended attributes exists on macOS. + /// Action: Create archive with --mac-metadata and extract it. + /// Expectation: Extended attributes are preserved in the archive and restored on extraction. + #[test] + fn stdio_mac_metadata_preserves_xattrs() { + setup(); + fs::create_dir_all("stdio_mac_metadata_xattr_dir").unwrap(); + fs::write("stdio_mac_metadata_xattr_dir/test.txt", "test content").unwrap(); + fs::create_dir_all("stdio_mac_metadata_xattr_dir/out").unwrap(); + + // Set xattr + let status = Command::new("xattr") + .args([ + "-w", + "com.example.test", + "test_value", + "stdio_mac_metadata_xattr_dir/test.txt", + ]) + .status() + .expect("Failed to run xattr"); + assert!( + status.success(), + "xattr failed with code: {:?}", + status.code() + ); + + // Create archive with --mac-metadata + cargo_bin_cmd!("pna") + .args([ + "experimental", + "stdio", + "-c", + "--unstable", + "--mac-metadata", + "--overwrite", + "-f", + "stdio_mac_metadata_xattr_dir/test.pna", + "stdio_mac_metadata_xattr_dir/test.txt", + ]) + .assert() + .success(); + + // Extract with --mac-metadata + cargo_bin_cmd!("pna") + .args([ + "experimental", + "stdio", + "-x", + "--unstable", + "--mac-metadata", + "-f", + "stdio_mac_metadata_xattr_dir/test.pna", + "--out-dir", + "stdio_mac_metadata_xattr_dir/out", + "--overwrite", + ]) + .assert() + .success(); + + // Verify xattr is preserved + let output = Command::new("xattr") + .args([ + "-p", + "com.example.test", + "stdio_mac_metadata_xattr_dir/out/stdio_mac_metadata_xattr_dir/test.txt", + ]) + .output() + .expect("Failed to read xattr"); + + assert!(output.status.success()); + let extracted_value = String::from_utf8_lossy(&output.stdout); + assert_eq!(extracted_value.trim(), "test_value"); + } + + /// Precondition: A file with extended attributes exists on macOS. + /// Action: Create archive with --no-mac-metadata and extract it. + /// Expectation: Extended attributes are NOT preserved. + #[test] + fn stdio_no_mac_metadata_excludes_xattrs() { + setup(); + fs::create_dir_all("stdio_no_mac_metadata_xattr_dir").unwrap(); + fs::write("stdio_no_mac_metadata_xattr_dir/test.txt", "test content").unwrap(); + fs::create_dir_all("stdio_no_mac_metadata_xattr_dir/out").unwrap(); + + // Set xattr + let status = Command::new("xattr") + .args([ + "-w", + "com.example.test", + "test_value", + "stdio_no_mac_metadata_xattr_dir/test.txt", + ]) + .status() + .expect("Failed to run xattr"); + assert!( + status.success(), + "xattr failed with code: {:?}", + status.code() + ); + + // Create archive with --no-mac-metadata + cargo_bin_cmd!("pna") + .args([ + "experimental", + "stdio", + "-c", + "--unstable", + "--no-mac-metadata", + "--overwrite", + "-f", + "stdio_no_mac_metadata_xattr_dir/test.pna", + "stdio_no_mac_metadata_xattr_dir/test.txt", + ]) + .assert() + .success(); + + // Extract with --mac-metadata (even if we try to restore, nothing should be there) + cargo_bin_cmd!("pna") + .args([ + "experimental", + "stdio", + "-x", + "--unstable", + "--mac-metadata", + "-f", + "stdio_no_mac_metadata_xattr_dir/test.pna", + "--out-dir", + "stdio_no_mac_metadata_xattr_dir/out", + "--overwrite", + ]) + .assert() + .success(); + + // Verify xattr is NOT preserved + let output = Command::new("xattr") + .args([ + "-p", + "com.example.test", + "stdio_no_mac_metadata_xattr_dir/out/stdio_no_mac_metadata_xattr_dir/test.txt", + ]) + .output() + .expect("Failed to check xattr"); + + // xattr command should fail because the attribute doesn't exist + assert!(!output.status.success()); + } +}