Skip to content

Commit b4b2533

Browse files
committed
⚗️ Add --mac-metadata option to stdio command for AppleDouble support
Add --mac-metadata and --no-mac-metadata flags to preserve macOS extended attributes, ACLs, and resource forks using the AppleDouble format via copyfile(). - Introduce MacMetadataStrategy enum with Always/Never variants - Integrate with KeepOptions for consistent metadata handling - Add maMd chunk type for storing packed AppleDouble data - Support all stdio modes: create (-c), extract (-x), append (-r), update (-u) - Require --unstable flag as feature is experimental
1 parent 6ca40bc commit b4b2533

13 files changed

Lines changed: 662 additions & 43 deletions

File tree

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ path-slash = "0.2.1"
4040
pna = { version = "0.30.0", path = "../pna" }
4141
rayon = "1.11.0"
4242
regex = "1.12.2"
43+
scopeguard = "1.2.0"
4344
serde = { version = "1.0.228", features = ["derive"] }
4445
serde_json = "1.0.148"
4546
tabled = { version = "0.20.0", default-features = false, features = ["std", "ansi"] }
@@ -57,7 +58,6 @@ exacl = { version = "0.12.0", optional = true }
5758
nix = { version = "0.30.1", features = ["user", "fs", "ioctl"] }
5859

5960
[target.'cfg(windows)'.dependencies]
60-
scopeguard = "1.2.0"
6161
windows = { version = "0.62.2", features = [
6262
"Win32_Storage_FileSystem",
6363
"Win32_Security_Authorization",

cli/src/chunk.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
mod acl;
22
mod fflag;
3+
mod mac_metadata;
34

45
pub use acl::*;
56
pub use fflag::*;
7+
pub use mac_metadata::*;

cli/src/chunk/mac_metadata.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
use pna::ChunkType;
2+
3+
/// Private chunk type for macOS metadata (AppleDouble format).
4+
/// Name follows PNA chunk naming convention where case has semantic meaning:
5+
/// - lowercase first letter: ancillary (not critical)
6+
/// - lowercase second letter: private (not public)
7+
/// - uppercase third letter: reserved
8+
/// - lowercase fourth letter: safe to copy
9+
#[allow(non_upper_case_globals)]
10+
pub const maMd: ChunkType = unsafe { ChunkType::from_unchecked(*b"maMd") };

cli/src/command/append.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ use crate::{
77
Command, ask_password, check_password,
88
core::{
99
AclStrategy, CollectOptions, CollectedItem, CreateOptions, FflagsStrategy, KeepOptions,
10-
OwnerOptions, PathFilter, PathTransformers, PathnameEditor, PermissionStrategy,
11-
TimeFilterResolver, TimestampStrategyResolver, XattrStrategy, collect_items_from_paths,
12-
create_entry, entry_option,
10+
MacMetadataStrategy, OwnerOptions, PathFilter, PathTransformers, PathnameEditor,
11+
PermissionStrategy, TimeFilterResolver, TimestampStrategyResolver, XattrStrategy,
12+
collect_items_from_paths, create_entry, entry_option,
1313
re::{bsd::SubstitutionRule, gnu::TransformRule},
1414
read_paths, read_paths_stdin,
1515
},
@@ -392,6 +392,7 @@ fn append_to_archive(args: AppendCommand) -> anyhow::Result<()> {
392392
xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr),
393393
acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl),
394394
fflags_strategy: FflagsStrategy::Never,
395+
mac_metadata_strategy: MacMetadataStrategy::Never,
395396
};
396397
let owner_options = OwnerOptions::new(
397398
args.uname,

cli/src/command/core.rs

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,46 @@ impl FflagsStrategy {
179179
}
180180
}
181181

182+
/// Strategy for handling macOS metadata in AppleDouble format.
183+
/// When enabled, creates `._` prefixed entries containing AppleDouble data
184+
/// (extended attributes, ACLs, resource forks) for each file with Mac metadata.
185+
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
186+
pub(crate) enum MacMetadataStrategy {
187+
/// Do not create AppleDouble entries (default)
188+
#[default]
189+
Never,
190+
/// Create AppleDouble entries for files with Mac metadata (macOS only)
191+
Always,
192+
}
193+
194+
impl MacMetadataStrategy {
195+
/// Creates a strategy from CLI flags, considering platform.
196+
/// On non-macOS platforms, always returns Never regardless of flags.
197+
#[cfg(target_os = "macos")]
198+
pub(crate) const fn from_flags(mac_metadata: bool, no_mac_metadata: bool) -> Self {
199+
if no_mac_metadata {
200+
Self::Never
201+
} else if mac_metadata {
202+
Self::Always
203+
} else {
204+
Self::Never
205+
}
206+
}
207+
208+
#[cfg(not(target_os = "macos"))]
209+
pub(crate) const fn from_flags(_mac_metadata: bool, _no_mac_metadata: bool) -> Self {
210+
Self::Never
211+
}
212+
}
213+
182214
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
183215
pub(crate) struct KeepOptions {
184216
pub(crate) timestamp_strategy: TimestampStrategy,
185217
pub(crate) permission_strategy: PermissionStrategy,
186218
pub(crate) xattr_strategy: XattrStrategy,
187219
pub(crate) acl_strategy: AclStrategy,
188220
pub(crate) fflags_strategy: FflagsStrategy,
221+
pub(crate) mac_metadata_strategy: MacMetadataStrategy,
189222
}
190223

191224
/// Resolves CLI timestamp options into a `TimestampStrategy`.
@@ -735,8 +768,18 @@ pub(crate) fn apply_metadata(
735768
mode,
736769
));
737770
}
771+
// On macOS, when mac_metadata_strategy is Always, AppleDouble packing via copyfile()
772+
// already includes xattrs and ACLs. Skip separate handling to avoid duplication.
773+
#[cfg(target_os = "macos")]
774+
let skip_xattr_acl = matches!(
775+
keep_options.mac_metadata_strategy,
776+
MacMetadataStrategy::Always
777+
);
778+
#[cfg(not(target_os = "macos"))]
779+
let skip_xattr_acl = false;
780+
738781
#[cfg(feature = "acl")]
739-
{
782+
if !skip_xattr_acl {
740783
#[cfg(any(
741784
target_os = "linux",
742785
target_os = "freebsd",
@@ -767,21 +810,23 @@ pub(crate) fn apply_metadata(
767810
log::warn!("Please enable `acl` feature and rebuild and install pna.");
768811
}
769812
#[cfg(unix)]
770-
if let XattrStrategy::Always = keep_options.xattr_strategy {
771-
match utils::os::unix::fs::xattrs::get_xattrs(path) {
772-
Ok(xattrs) => {
773-
for attr in xattrs {
774-
entry.add_xattr(attr);
813+
if !skip_xattr_acl {
814+
if let XattrStrategy::Always = keep_options.xattr_strategy {
815+
match utils::os::unix::fs::xattrs::get_xattrs(path) {
816+
Ok(xattrs) => {
817+
for attr in xattrs {
818+
entry.add_xattr(attr);
819+
}
775820
}
821+
Err(e) if e.kind() == std::io::ErrorKind::Unsupported => {
822+
log::warn!(
823+
"Extended attributes are not supported on filesystem for '{}': {}",
824+
path.display(),
825+
e
826+
);
827+
}
828+
Err(e) => return Err(e),
776829
}
777-
Err(e) if e.kind() == std::io::ErrorKind::Unsupported => {
778-
log::warn!(
779-
"Extended attributes are not supported on filesystem for '{}': {}",
780-
path.display(),
781-
e
782-
);
783-
}
784-
Err(e) => return Err(e),
785830
}
786831
}
787832
#[cfg(not(unix))]
@@ -805,6 +850,40 @@ pub(crate) fn apply_metadata(
805850
Err(e) => return Err(e),
806851
}
807852
}
853+
// macOS metadata (AppleDouble) - packs xattrs, ACLs, resource forks via copyfile()
854+
#[cfg(target_os = "macos")]
855+
if let MacMetadataStrategy::Always = keep_options.mac_metadata_strategy {
856+
use pna::RawChunk;
857+
match utils::os::unix::fs::copyfile::pack_apple_double(path) {
858+
Ok(apple_double_data) => {
859+
if !apple_double_data.is_empty() {
860+
let len = apple_double_data.len();
861+
entry.add_extra_chunk(RawChunk::from_data(
862+
crate::chunk::maMd,
863+
apple_double_data,
864+
));
865+
log::debug!(
866+
"Packed macOS metadata for '{}' ({len} bytes)",
867+
path.display(),
868+
);
869+
}
870+
}
871+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
872+
// File has no Mac metadata, this is fine
873+
}
874+
Err(e) => {
875+
log::warn!(
876+
"Failed to pack macOS metadata for '{}': {}",
877+
path.display(),
878+
e
879+
);
880+
}
881+
}
882+
}
883+
#[cfg(not(target_os = "macos"))]
884+
if let MacMetadataStrategy::Always = keep_options.mac_metadata_strategy {
885+
log::warn!("macOS metadata (--mac-metadata) is only supported on macOS.");
886+
}
808887
Ok(entry)
809888
}
810889

cli/src/command/create.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ use crate::{
77
Command, ask_password, check_password,
88
core::{
99
AclStrategy, CollectOptions, CollectedItem, CreateOptions, FflagsStrategy, KeepOptions,
10-
MIN_SPLIT_PART_BYTES, OwnerOptions, PathFilter, PathTransformers, PathnameEditor,
11-
PermissionStrategy, TimeFilterResolver, TimestampStrategyResolver, XattrStrategy,
12-
collect_items_from_paths, create_entry, entry_option,
10+
MIN_SPLIT_PART_BYTES, MacMetadataStrategy, OwnerOptions, PathFilter, PathTransformers,
11+
PathnameEditor, PermissionStrategy, TimeFilterResolver, TimestampStrategyResolver,
12+
XattrStrategy, collect_items_from_paths, create_entry, entry_option,
1313
re::{bsd::SubstitutionRule, gnu::TransformRule},
1414
read_paths, read_paths_stdin, write_split_archive,
1515
},
@@ -478,6 +478,7 @@ fn create_archive(args: CreateCommand) -> anyhow::Result<()> {
478478
xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr),
479479
acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl),
480480
fflags_strategy: FflagsStrategy::Never,
481+
mac_metadata_strategy: MacMetadataStrategy::Never,
481482
};
482483
let owner_options = OwnerOptions::new(
483484
args.uname,

cli/src/command/extract.rs

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ use crate::{
88
command::{
99
Command, ask_password,
1010
core::{
11-
AclStrategy, FflagsStrategy, KeepOptions, OwnerOptions, PathFilter, PathTransformers,
12-
PathnameEditor, PermissionStrategy, TimeFilterResolver, TimeFilters, TimestampStrategy,
13-
TimestampStrategyResolver, XattrStrategy, apply_chroot, collect_split_archives,
11+
AclStrategy, FflagsStrategy, KeepOptions, MacMetadataStrategy, OwnerOptions,
12+
PathFilter, PathTransformers, PathnameEditor, PermissionStrategy, TimeFilterResolver,
13+
TimeFilters, TimestampStrategy, TimestampStrategyResolver, XattrStrategy, apply_chroot,
14+
collect_split_archives,
1415
path_lock::PathLocks,
1516
re::{bsd::SubstitutionRule, gnu::TransformRule},
1617
read_paths, run_process_archive,
@@ -420,6 +421,7 @@ fn extract_archive(args: ExtractCommand) -> anyhow::Result<()> {
420421
xattr_strategy: XattrStrategy::from_flags(args.keep_xattr, args.no_keep_xattr),
421422
acl_strategy: AclStrategy::from_flags(args.keep_acl, args.no_keep_acl),
422423
fflags_strategy: FflagsStrategy::Never,
424+
mac_metadata_strategy: MacMetadataStrategy::Never,
423425
};
424426
let owner_options = OwnerOptions::new(
425427
args.uname,
@@ -872,9 +874,9 @@ fn restore_timestamps(
872874
Ok(())
873875
}
874876

875-
/// Restores file metadata (permissions, extended attributes, and ACLs) for an extracted entry according to the provided keep and owner options.
877+
/// Restores file metadata (permissions, extended attributes, ACLs, and macOS metadata) for an extracted entry according to the provided keep and owner options.
876878
///
877-
/// 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.
879+
/// 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.
878880
fn restore_metadata<T>(
879881
item: &NormalEntry<T>,
880882
path: &Path,
@@ -890,26 +892,41 @@ where
890892
restore_permissions(*same_owner, path, p, owner_options)?;
891893
}
892894
}
895+
// On macOS, when mac_metadata_strategy is Always and the entry has mac_metadata,
896+
// AppleDouble restoration via copyfile() will include xattrs and ACLs.
897+
// Skip separate handling to avoid duplication.
898+
#[cfg(target_os = "macos")]
899+
let skip_xattr_acl = matches!(
900+
keep_options.mac_metadata_strategy,
901+
MacMetadataStrategy::Always
902+
) && item.mac_metadata().is_some();
903+
#[cfg(not(target_os = "macos"))]
904+
let skip_xattr_acl = false;
905+
893906
#[cfg(unix)]
894-
if let XattrStrategy::Always = keep_options.xattr_strategy {
895-
match utils::os::unix::fs::xattrs::set_xattrs(path, item.xattrs()) {
896-
Ok(()) => {}
897-
Err(e) if e.kind() == io::ErrorKind::Unsupported => {
898-
log::warn!(
899-
"Extended attributes are not supported on filesystem for '{}': {}",
900-
path.display(),
901-
e
902-
);
907+
if !skip_xattr_acl {
908+
if let XattrStrategy::Always = keep_options.xattr_strategy {
909+
match utils::os::unix::fs::xattrs::set_xattrs(path, item.xattrs()) {
910+
Ok(()) => {}
911+
Err(e) if e.kind() == io::ErrorKind::Unsupported => {
912+
log::warn!(
913+
"Extended attributes are not supported on filesystem for '{}': {}",
914+
path.display(),
915+
e
916+
);
917+
}
918+
Err(e) => return Err(e),
903919
}
904-
Err(e) => return Err(e),
905920
}
906921
}
907922
#[cfg(not(unix))]
908923
if let XattrStrategy::Always = keep_options.xattr_strategy {
909924
log::warn!("Currently extended attribute is not supported on this platform.");
910925
}
911926
#[cfg(feature = "acl")]
912-
restore_acls(path, item.acl()?, keep_options.acl_strategy)?;
927+
if !skip_xattr_acl {
928+
restore_acls(path, item.acl()?, keep_options.acl_strategy)?;
929+
}
913930
#[cfg(not(feature = "acl"))]
914931
if let AclStrategy::Always = keep_options.acl_strategy {
915932
log::warn!("Please enable `acl` feature and rebuild and install pna.");
@@ -930,6 +947,33 @@ where
930947
}
931948
}
932949
}
950+
// macOS metadata (AppleDouble) - restores xattrs, ACLs, resource forks via copyfile()
951+
#[cfg(target_os = "macos")]
952+
if let MacMetadataStrategy::Always = keep_options.mac_metadata_strategy {
953+
if let Some(apple_double_data) = item.mac_metadata() {
954+
match utils::os::unix::fs::copyfile::unpack_apple_double(apple_double_data, path) {
955+
Ok(()) => {
956+
log::debug!("Unpacked macOS metadata for '{}'", path.display());
957+
}
958+
Err(e) => {
959+
log::warn!(
960+
"Failed to restore macOS metadata for '{}': {}",
961+
path.display(),
962+
e
963+
);
964+
}
965+
}
966+
}
967+
}
968+
#[cfg(not(target_os = "macos"))]
969+
if let MacMetadataStrategy::Always = keep_options.mac_metadata_strategy {
970+
if item.mac_metadata().is_some() {
971+
log::warn!(
972+
"macOS metadata present but cannot be restored on this platform: '{}'",
973+
path.display()
974+
);
975+
}
976+
}
933977
Ok(())
934978
}
935979

0 commit comments

Comments
 (0)