From 0b3b912188764b54ec4b464dac269bd408a14727 Mon Sep 17 00:00:00 2001 From: ChanTsune <41658782+ChanTsune@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:12:49 +0900 Subject: [PATCH] :alembic: add `@archive` support in stdio subcommand Redesign @archive inclusion to preserve CLI argument order exactly. The previous implementation separated filesystem paths and @archives before processing, which lost the original ordering. Key changes: - Add ItemSource, ArchiveSource, CollectedItem types to core.rs - Add collect_items_from_sources for unified processing - Process arguments one by one, maintaining order - Support @- for reading archives from stdin - Detect stdin conflicts (input archive from stdin vs @-) --- cli/src/command/append.rs | 44 +- cli/src/command/core.rs | 435 ++++++++++++- cli/src/command/create.rs | 94 ++- cli/src/command/stdio.rs | 48 +- cli/src/command/update.rs | 8 +- cli/tests/cli/stdio.rs | 1 + cli/tests/cli/stdio/archive_inclusion.rs | 743 +++++++++++++++++++++++ 7 files changed, 1274 insertions(+), 99 deletions(-) create mode 100644 cli/tests/cli/stdio/archive_inclusion.rs diff --git a/cli/src/command/append.rs b/cli/src/command/append.rs index a4c401358..47d3bd6bd 100644 --- a/cli/src/command/append.rs +++ b/cli/src/command/append.rs @@ -8,10 +8,10 @@ use crate::{ core::{ AclStrategy, CollectOptions, CollectedItem, CreateOptions, FflagsStrategy, KeepOptions, MacMetadataStrategy, PathFilter, PathTransformers, PathnameEditor, - PermissionStrategyResolver, TimeFilterResolver, TimestampStrategyResolver, - XattrStrategy, collect_items_from_paths, create_entry, entry_option, + PermissionStrategyResolver, TimeFilterResolver, TimeFilters, TimestampStrategyResolver, + XattrStrategy, collect_items_from_paths, drain_entry_results, entry_option, re::{bsd::SubstitutionRule, gnu::TransformRule}, - read_paths, read_paths_stdin, + read_paths, read_paths_stdin, spawn_entry_results, }, }, utils::{PathPartExt, VCS_FILES, fs::HardlinkResolver}, @@ -460,35 +460,31 @@ fn append_to_archive(args: AppendCommand) -> anyhow::Result<()> { time_filters: &time_filters, }; let mut resolver = HardlinkResolver::new(collect_options.follow_links); - let target_items = collect_items_from_paths(&files, &collect_options, &mut resolver)?; + let target_items = collect_items_from_paths(&files, &collect_options, &mut resolver)? + .into_iter() + .map(CollectedItem::Filesystem) + .collect::>(); - run_append_archive(&create_options, archive, target_items) + run_append_archive( + &create_options, + archive, + target_items, + &filter, + &time_filters, + password, + ) } pub(crate) fn run_append_archive( create_options: &CreateOptions, mut archive: Archive, target_items: Vec, + filter: &PathFilter<'_>, + time_filters: &TimeFilters, + password: Option<&[u8]>, ) -> anyhow::Result<()> { - let (tx, rx) = std::sync::mpsc::channel(); - rayon::scope_fifo(|s| { - for item in target_items { - let tx = tx.clone(); - s.spawn_fifo(move |_| { - log::debug!("Adding: {}", item.path.display()); - tx.send(create_entry(&item, create_options)) - .unwrap_or_else(|e| log::error!("{e}: {}", item.path.display())); - }) - } - - drop(tx); - }); - - for entry in rx.into_iter() { - if let Some(entry) = entry? { - archive.add_entry(entry)?; - } - } + let rx = spawn_entry_results(target_items, create_options, filter, time_filters, password); + drain_entry_results(rx, |entry| archive.add_entry(entry))?; archive.finalize()?; Ok(()) } diff --git a/cli/src/command/core.rs b/cli/src/command/core.rs index ca8dfd2ca..45a4722de 100644 --- a/cli/src/command/core.rs +++ b/cli/src/command/core.rs @@ -27,7 +27,7 @@ use pna::{ use std::{ borrow::Cow, collections::HashMap, - fs, + fmt, fs, io::{self, prelude::*}, path::{Path, PathBuf}, time::SystemTime, @@ -377,12 +377,14 @@ impl Ignore { } } -pub(crate) struct CollectedItem { +#[derive(Clone, Debug)] +pub(crate) struct CollectedEntry { pub(crate) path: PathBuf, pub(crate) store_as: StoreAs, pub(crate) metadata: fs::Metadata, } +#[derive(Clone, Debug)] pub(crate) enum StoreAs { File, Dir, @@ -390,6 +392,213 @@ pub(crate) enum StoreAs { Hardlink(PathBuf), } +/// Source of an archive to include (file path or stdin). +#[derive(Clone, Debug)] +pub(crate) enum ArchiveSource { + File(PathBuf), + Stdin, +} + +impl fmt::Display for ArchiveSource { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::File(path) => fmt::Display::fmt(&path.display(), f), + Self::Stdin => f.write_str("-"), + } + } +} + +/// Represents a CLI file argument that can be either a filesystem path or an archive inclusion. +/// +/// Archive inclusions start with '@' and reference entries from an existing archive. +/// This follows bsdtar's convention for including archives. +#[derive(Clone, Debug)] +pub(crate) enum ItemSource { + /// A regular filesystem path (file or directory). + Filesystem(PathBuf), + /// An archive to include entries from. + Archive(ArchiveSource), +} + +impl ItemSource { + /// Parses a single CLI argument into an `ItemSource`. + /// + /// - `@` or `@-` → `Archive(Stdin)` + /// - `@path` → `Archive(File(path))` + /// - `path` → `Filesystem(path)` + /// + /// To include a file whose name starts with `@`, use `./@filename` (bsdtar convention). + /// + /// Paths are stored as-is. Both filesystem and archive paths are resolved + /// relative to the current working directory at the time they are accessed, + /// which means they are affected by the -C option. + pub(crate) fn parse(arg: &str) -> Self { + if let Some(archive_path) = arg.strip_prefix('@') { + if archive_path.is_empty() || archive_path == "-" { + Self::Archive(ArchiveSource::Stdin) + } else { + Self::Archive(ArchiveSource::File(PathBuf::from(archive_path))) + } + } else { + Self::Filesystem(PathBuf::from(arg)) + } + } + + /// Parses multiple CLI arguments into `ItemSource` values. + pub(crate) fn parse_many(args: &[String]) -> Vec { + args.iter().map(|s| Self::parse(s)).collect() + } +} + +/// Validates that stdin is not used as a source more than once. +/// +/// Returns an error if multiple `@-` or `@` sources are found, +/// since stdin can only be read once. +pub(crate) fn validate_no_duplicate_stdin(sources: &[ItemSource]) -> anyhow::Result<()> { + let stdin_count = sources + .iter() + .filter(|s| matches!(s, ItemSource::Archive(ArchiveSource::Stdin))) + .count(); + if stdin_count > 1 { + anyhow::bail!("stdin (@- or @) can only be specified once as an archive source"); + } + Ok(()) +} + +/// Represents a collected item ready for archive creation. +/// +/// This preserves the CLI argument order while separating filesystem items +/// (which need entry building) from archive markers (which need entry copying). +#[derive(Clone, Debug)] +pub(crate) enum CollectedItem { + /// A filesystem item with its path and storage strategy. + Filesystem(CollectedEntry), + /// A marker indicating where to insert entries from an archive source. + ArchiveMarker(ArchiveSource), +} + +/// Result type for entries sent through the channel. +/// +/// This enum allows batching multiple archive entries into a single channel send +/// operation, reducing synchronization overhead compared to sending each entry +/// individually. +#[allow(clippy::large_enum_variant)] +pub(crate) enum EntryResult { + /// A single entry from a filesystem item. + Single(io::Result>), + /// A batch of entries from an archive source. + Batch(io::Result>>>), +} + +impl EntryResult { + pub(crate) fn into_entries(self) -> Vec>> { + match self { + EntryResult::Single(entry) => vec![entry], + EntryResult::Batch(entries) => entries.unwrap_or_else(|e| vec![Err(e)]), + } + } +} + +/// Drains entry results and applies a callback to each emitted entry. +pub(crate) fn drain_entry_results(results: I, mut add_entry: F) -> io::Result<()> +where + I: IntoIterator, + F: FnMut(NormalEntry) -> io::Result, +{ + for result in results { + match result { + EntryResult::Single(entry) => { + if let Some(entry) = entry? { + add_entry(entry)?; + } + } + EntryResult::Batch(entries) => { + for entry in entries? { + if let Some(entry) = entry? { + add_entry(entry)?; + } + } + } + } + } + Ok(()) +} + +/// Spawns entry creation for filesystem items and reads archive sources. +pub(crate) fn spawn_entry_results( + target_items: Vec, + create_options: &CreateOptions, + filter: &PathFilter<'_>, + time_filters: &TimeFilters, + password: Option<&[u8]>, +) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + rayon::scope_fifo(|s| { + for item in target_items { + match item { + CollectedItem::Filesystem(entry) => { + let tx = tx.clone(); + s.spawn_fifo(move |_| { + log::debug!("Adding: {}", entry.path.display()); + tx.send(EntryResult::Single(create_entry(&entry, create_options))) + .unwrap_or_else(|e| log::error!("{e}: {}", entry.path.display())); + }) + } + CollectedItem::ArchiveMarker(source) => { + let result = read_archive_source( + &source, + create_options, + filter, + time_filters, + password, + ); + tx.send(EntryResult::Batch(result)) + .unwrap_or_else(|e| log::error!("{e}: archive source {}", source)); + } + } + } + + drop(tx); + }); + rx +} + +/// Collects items from mixed filesystem and archive sources, preserving order. +/// +/// For filesystem sources, uses the existing collection logic with shared +/// hardlink detection. For archive sources, returns markers that indicate +/// where archive entries should be inserted. +/// +/// # Order Guarantee +/// - Between arguments: strictly preserved +/// - Within a single filesystem argument: walkdir traversal order +/// +/// # Hardlink Detection +/// A single `HardlinkResolver` is shared across all filesystem paths, +/// enabling cross-path hardlink detection. +pub(crate) fn collect_items_from_sources( + sources: impl IntoIterator, + options: &CollectOptions<'_>, + hardlink_resolver: &mut HardlinkResolver, +) -> io::Result> { + let mut results = Vec::new(); + + for source in sources { + match source { + ItemSource::Filesystem(path) => { + let items = collect_items_with_state(&path, options, hardlink_resolver)?; + results.extend(items.into_iter().map(CollectedItem::Filesystem)); + } + ItemSource::Archive(archive_source) => { + results.push(CollectedItem::ArchiveMarker(archive_source)); + } + } + } + + Ok(results) +} + /// Collects items from multiple paths, preserving CLI argument order. /// /// State such as hardlink detection is shared across all paths via the provided @@ -405,7 +614,7 @@ pub(crate) fn collect_items_from_paths>( paths: impl IntoIterator, options: &CollectOptions<'_>, hardlink_resolver: &mut HardlinkResolver, -) -> io::Result> { +) -> io::Result> { let mut results = Vec::new(); for path in paths { results.extend(collect_items_with_state( @@ -419,7 +628,7 @@ pub(crate) fn collect_items_from_paths>( /// Walks a single path and collects filesystem items to archive. /// -/// Returns a list of `CollectedItem` indicating how each discovered item +/// Returns a list of [`CollectedEntry`] indicating how each discovered item /// should be stored in the archive (`StoreAs`), along with its pre-captured /// metadata. Traversal supports recursion, exclusion filters, and correct /// handling of symbolic and hard links. @@ -455,7 +664,7 @@ pub(crate) fn collect_items_from_paths>( /// This function accepts a shared `HardlinkResolver` to enable cross-path hardlink /// detection when collecting from multiple paths. /// -/// Returns a vector of `CollectedItem` on success. +/// Returns a vector of [`CollectedEntry`] on success. /// /// # Errors /// Propagates I/O errors encountered during traversal. Broken symlinks are @@ -466,7 +675,7 @@ pub(crate) fn collect_items_with_state( path: &Path, options: &CollectOptions<'_>, hardlink_resolver: &mut HardlinkResolver, -) -> io::Result> { +) -> io::Result> { let mut ig = Ignore::empty(); let mut out = Vec::new(); @@ -556,7 +765,7 @@ pub(crate) fn collect_items_with_state( .time_filters .matches_or_inactive(metadata.created().ok(), metadata.modified().ok()) { - out.push(CollectedItem { + out.push(CollectedEntry { path: path.to_path_buf(), store_as, metadata, @@ -569,7 +778,7 @@ pub(crate) fn collect_items_with_state( if let Some(path) = e.path() { let metadata = fs::symlink_metadata(path)?; if is_broken_symlink_error(&metadata, ioe) { - out.push(CollectedItem { + out.push(CollectedEntry { path: path.to_path_buf(), store_as: StoreAs::Symlink, metadata, @@ -646,14 +855,14 @@ pub(crate) fn write_from_path(writer: &mut impl Write, path: impl AsRef) - } pub(crate) fn create_entry( - item: &CollectedItem, + item: &CollectedEntry, CreateOptions { option, keep_options, pathname_editor, }: &CreateOptions, ) -> io::Result> { - let CollectedItem { + let CollectedEntry { path, store_as, metadata, @@ -1353,6 +1562,120 @@ pub(crate) fn apply_chroot(chroot: bool) -> anyhow::Result<()> { Ok(()) } +/// Transforms entries from a source archive, applying path and ownership transformations. +/// +/// This function reads entries from the source archive and yields transformed entries +/// that can be added to a target archive. For solid entries, they are expanded to +/// individual normal entries. Encrypted solid entries can be decrypted and expanded +/// if a password is provided; without a password they will cause an error. +/// +/// The entry data (FDAT chunks) is preserved as-is, maintaining the original +/// compression and encryption. Only the entry headers (paths, ownership) are modified. +/// +/// Entries whose path matches the filter exclusion rules will be skipped. +/// Time filters are also applied to filter entries by timestamps. +pub(crate) fn transform_archive_entries( + reader: R, + create_options: &CreateOptions, + filter: &PathFilter<'_>, + time_filters: &TimeFilters, + password: Option<&[u8]>, +) -> io::Result>>> { + let mut archive = Archive::read_header(reader)?; + let mut results = Vec::new(); + + for entry_result in archive.entries().extract_solid_entries(password) { + match entry_result { + Ok(entry) => { + if filter.excluded(entry.header().path()) { + continue; + } + let ctime = entry.metadata().created_time(); + let mtime = entry.metadata().modified_time(); + if !time_filters.matches_or_inactive(ctime, mtime) { + continue; + } + results.push(transform_normal_entry(entry, create_options)); + } + Err(e) => results.push(Err(e)), + } + } + + Ok(results) +} + +/// Reads entries from an archive source (file or stdin) and transforms them. +pub(crate) fn read_archive_source( + source: &ArchiveSource, + create_options: &CreateOptions, + filter: &PathFilter<'_>, + time_filters: &TimeFilters, + password: Option<&[u8]>, +) -> io::Result>>> { + match source { + ArchiveSource::File(path) => { + let file = fs::File::open(path) + .map_err(|e| io::Error::new(e.kind(), format!("{}: {}", path.display(), e)))?; + let reader = io::BufReader::with_capacity(64 * 1024, file); + transform_archive_entries(reader, create_options, filter, time_filters, password) + .map_err(|e| io::Error::new(e.kind(), format!("{}: {}", path.display(), e))) + } + ArchiveSource::Stdin => { + let reader = io::BufReader::new(io::stdin().lock()); + transform_archive_entries(reader, create_options, filter, time_filters, password) + .map_err(|e| io::Error::new(e.kind(), format!(": {}", e))) + } + } +} + +/// Transforms a single normal entry, applying path and ownership modifications. +fn transform_normal_entry( + entry: NormalEntry, + CreateOptions { + pathname_editor, + keep_options, + .. + }: &CreateOptions, +) -> io::Result> { + // Apply path transformation + let original_name = entry.header().path(); + let Some(new_name) = pathname_editor.edit_entry_name(original_name.as_ref()) else { + // Entry path was stripped away entirely + return Ok(None); + }; + + let mut result = entry.with_name(new_name); + + // Apply ownership overrides from owner_strategy + if let OwnerStrategy::Preserve { + options: + OwnerOptions { + uid, + gid, + uname, + gname, + }, + } = &keep_options.owner_strategy + { + // Only apply if at least one override is specified + if uid.is_some() || gid.is_some() || uname.is_some() || gname.is_some() { + if let Some(perm) = result.metadata().permission() { + let new_perm = pna::Permission::new( + uid.map(u64::from).unwrap_or_else(|| perm.uid()), + uname.clone().unwrap_or_else(|| perm.uname().to_string()), + gid.map(u64::from).unwrap_or_else(|| perm.gid()), + gname.clone().unwrap_or_else(|| perm.gname().to_string()), + perm.permissions(), + ); + let metadata = result.metadata().clone().with_permission(Some(new_perm)); + result = result.with_metadata(metadata); + } + } + } + + Ok(Some(result)) +} + #[cfg(test)] mod tests { use super::*; @@ -1483,4 +1806,96 @@ mod tests { .collect::>() ); } + + mod item_source_parse { + use super::*; + + #[test] + fn at_alone_is_stdin() { + let result = ItemSource::parse("@"); + assert!(matches!(result, ItemSource::Archive(ArchiveSource::Stdin))); + } + + #[test] + fn at_dash_is_stdin() { + let result = ItemSource::parse("@-"); + assert!(matches!(result, ItemSource::Archive(ArchiveSource::Stdin))); + } + + #[test] + fn at_path_is_archive_file() { + let result = ItemSource::parse("@archive.pna"); + assert!(matches!( + result, + ItemSource::Archive(ArchiveSource::File(p)) if p == Path::new("archive.pna") + )); + } + + #[test] + fn plain_path_is_filesystem() { + let result = ItemSource::parse("some/path"); + assert!(matches!( + result, + ItemSource::Filesystem(p) if p == Path::new("some/path") + )); + } + + #[test] + fn dot_slash_at_is_filesystem_escape() { + // Following bsdtar convention: ./@file escapes the @ prefix + let result = ItemSource::parse("./@file"); + assert!(matches!( + result, + ItemSource::Filesystem(p) if p == Path::new("./@file") + )); + } + + #[test] + fn parse_many_mixed() { + let args = vec![ + "file1".to_string(), + "@archive.pna".to_string(), + "@".to_string(), + "./@literal".to_string(), + ]; + let results = ItemSource::parse_many(&args); + assert_eq!(results.len(), 4); + assert!(matches!(&results[0], ItemSource::Filesystem(p) if p == Path::new("file1"))); + assert!( + matches!(&results[1], ItemSource::Archive(ArchiveSource::File(p)) if p == Path::new("archive.pna")) + ); + assert!(matches!( + &results[2], + ItemSource::Archive(ArchiveSource::Stdin) + )); + assert!( + matches!(&results[3], ItemSource::Filesystem(p) if p == Path::new("./@literal")) + ); + } + + #[test] + fn validate_no_duplicate_stdin_ok() { + let sources = vec![ + ItemSource::Filesystem(PathBuf::from("file")), + ItemSource::Archive(ArchiveSource::Stdin), + ItemSource::Archive(ArchiveSource::File(PathBuf::from("archive.pna"))), + ]; + assert!(super::validate_no_duplicate_stdin(&sources).is_ok()); + } + + #[test] + fn validate_no_duplicate_stdin_error() { + let sources = vec![ + ItemSource::Archive(ArchiveSource::Stdin), + ItemSource::Archive(ArchiveSource::Stdin), + ]; + assert!(super::validate_no_duplicate_stdin(&sources).is_err()); + } + + #[test] + fn validate_no_duplicate_stdin_empty() { + let sources: Vec = vec![]; + assert!(super::validate_no_duplicate_stdin(&sources).is_ok()); + } + } } diff --git a/cli/src/command/create.rs b/cli/src/command/create.rs index 9a3428f8b..a0b16254d 100644 --- a/cli/src/command/create.rs +++ b/cli/src/command/create.rs @@ -6,13 +6,13 @@ use crate::{ command::{ Command, ask_password, check_password, core::{ - AclStrategy, CollectOptions, CollectedItem, CreateOptions, FflagsStrategy, KeepOptions, - MIN_SPLIT_PART_BYTES, MacMetadataStrategy, PathFilter, PathTransformers, - PathnameEditor, PermissionStrategyResolver, TimeFilterResolver, - TimestampStrategyResolver, XattrStrategy, collect_items_from_paths, create_entry, - entry_option, + AclStrategy, CollectOptions, CollectedItem, CreateOptions, EntryResult, FflagsStrategy, + KeepOptions, MIN_SPLIT_PART_BYTES, MacMetadataStrategy, PathFilter, PathTransformers, + PathnameEditor, PermissionStrategyResolver, TimeFilterResolver, TimeFilters, + TimestampStrategyResolver, XattrStrategy, collect_items_from_paths, + drain_entry_results, entry_option, re::{bsd::SubstitutionRule, gnu::TransformRule}, - read_paths, read_paths_stdin, write_split_archive, + read_paths, read_paths_stdin, spawn_entry_results, write_split_archive, }, }, utils::{self, VCS_FILES, fmt::DurationDisplay, fs::HardlinkResolver}, @@ -454,7 +454,10 @@ fn create_archive(args: CreateCommand) -> anyhow::Result<()> { time_filters: &time_filters, }; let mut resolver = HardlinkResolver::new(collect_options.follow_links); - let target_items = collect_items_from_paths(&files, &collect_options, &mut resolver)?; + let target_items = collect_items_from_paths(&files, &collect_options, &mut resolver)? + .into_iter() + .map(CollectedItem::Filesystem) + .collect::>(); if let Some(parent) = archive_path.parent() { fs::create_dir_all(parent)?; @@ -510,12 +513,18 @@ fn create_archive(args: CreateCommand) -> anyhow::Result<()> { target_items, size, args.overwrite, + &filter, + &time_filters, + password, )?; } else { create_archive_file( || utils::fs::file_create(&archive_path, args.overwrite), creation_context, target_items, + &filter, + &time_filters, + password, )?; } log::info!( @@ -541,12 +550,14 @@ pub(crate) fn create_archive_file( pathname_editor, }: CreationContext, target_items: Vec, + filter: &PathFilter<'_>, + time_filters: &TimeFilters, + password: Option<&[u8]>, ) -> anyhow::Result<()> where W: Write, F: FnMut() -> io::Result + Send, { - let (tx, rx) = std::sync::mpsc::channel(); let option = if solid { WriteOptions::store() } else { @@ -557,42 +568,29 @@ where keep_options, pathname_editor, }; - rayon::scope_fifo(|s| { - for item in target_items { - let tx = tx.clone(); - let create_options = create_options.clone(); - s.spawn_fifo(move |_| { - log::debug!("Adding: {}", item.path.display()); - tx.send(create_entry(&item, &create_options)) - .unwrap_or_else(|e| log::error!("{e}: {}", item.path.display())); - }) - } - - drop(tx); - }); + let rx = spawn_entry_results( + target_items, + &create_options, + filter, + time_filters, + password, + ); let file = get_writer()?; let buffered = io::BufWriter::with_capacity(64 * 1024, file); if solid { let mut writer = Archive::write_solid_header(buffered, write_option)?; - for entry in rx.into_iter() { - if let Some(entry) = entry? { - writer.add_entry(entry)?; - } - } + drain_entry_results(rx, |entry| writer.add_entry(entry))?; writer.finalize()?; } else { let mut writer = Archive::write_header(buffered)?; - for entry in rx.into_iter() { - if let Some(entry) = entry? { - writer.add_entry(entry)?; - } - } + drain_entry_results(rx, |entry| writer.add_entry(entry))?; writer.finalize()?; } Ok(()) } +#[allow(clippy::too_many_arguments)] fn create_archive_with_split( archive: &Path, CreationContext { @@ -604,8 +602,10 @@ fn create_archive_with_split( target_items: Vec, max_file_size: usize, overwrite: bool, + filter: &PathFilter<'_>, + time_filters: &TimeFilters, + password: Option<&[u8]>, ) -> anyhow::Result<()> { - let (tx, rx) = std::sync::mpsc::channel(); let option = if solid { WriteOptions::store() } else { @@ -616,33 +616,23 @@ fn create_archive_with_split( keep_options, pathname_editor, }; - rayon::scope_fifo(|s| -> anyhow::Result<()> { - for item in target_items { - let tx = tx.clone(); - let create_options = create_options.clone(); - s.spawn_fifo(move |_| { - log::debug!("Adding: {}", item.path.display()); - tx.send(create_entry(&item, &create_options)) - .unwrap_or_else(|e| log::error!("{e}: {}", item.path.display())); - }) - } - - drop(tx); - Ok(()) - })?; + let rx = spawn_entry_results( + target_items, + &create_options, + filter, + time_filters, + password, + ); if solid { let mut entries_builder = SolidEntryBuilder::new(write_option)?; - for entry in rx.into_iter() { - if let Some(entry) = entry? { - entries_builder.add_entry(entry)?; - } - } + drain_entry_results(rx, |entry| entries_builder.add_entry(entry))?; let entries = entries_builder.build(); write_split_archive(archive, [entries].into_iter(), max_file_size, overwrite)?; } else { + let entries = rx.into_iter().flat_map(EntryResult::into_entries); write_split_archive( archive, - rx.into_iter().filter_map(Result::transpose), + entries.filter_map(Result::transpose), max_file_size, overwrite, )?; diff --git a/cli/src/command/stdio.rs b/cli/src/command/stdio.rs index bcb2ceb9e..872b6abdf 100644 --- a/cli/src/command/stdio.rs +++ b/cli/src/command/stdio.rs @@ -8,14 +8,14 @@ use crate::{ append::{open_archive_then_seek_to_end, run_append_archive}, ask_password, check_password, core::{ - AclStrategy, CollectOptions, CreateOptions, FflagsStrategy, KeepOptions, + AclStrategy, CollectOptions, CreateOptions, FflagsStrategy, ItemSource, KeepOptions, MacMetadataStrategy, PathFilter, PathTransformers, PathnameEditor, PermissionStrategyResolver, TimeFilterResolver, TimestampStrategyResolver, TransformStrategyUnSolid, XattrStrategy, apply_chroot, collect_items_from_paths, - collect_split_archives, entry_option, + collect_items_from_sources, collect_split_archives, entry_option, path_lock::PathLocks, re::{bsd::SubstitutionRule, gnu::TransformRule}, - read_paths, + read_paths, validate_no_duplicate_stdin, }, create::{CreationContext, create_archive_file}, extract::{OutputOption, OverwriteStrategy, run_extract_archive_reader}, @@ -616,6 +616,9 @@ fn run_create_archive(args: StdioCommand) -> anyhow::Result<()> { if let Some(working_dir) = args.working_dir { env::set_current_dir(working_dir)?; } + // Parse sources AFTER changing directory so @archive paths are affected by -C + let sources = ItemSource::parse_many(&files); + validate_no_duplicate_stdin(&sources)?; let collect_options = CollectOptions { recursive: !args.no_recursive, keep_dir: args.keep_dir, @@ -628,7 +631,7 @@ fn run_create_archive(args: StdioCommand) -> anyhow::Result<()> { time_filters: &time_filters, }; let mut resolver = HardlinkResolver::new(collect_options.follow_links); - let target_items = collect_items_from_paths(&files, &collect_options, &mut resolver)?; + let target_items = collect_items_from_sources(sources, &collect_options, &mut resolver)?; if args.check_links { for (path, expected, archived) in resolver.incomplete_links() { log::warn!( @@ -693,9 +696,19 @@ fn run_create_archive(args: StdioCommand) -> anyhow::Result<()> { || utils::fs::file_create(&file, args.overwrite), creation_context, target_items, + &filter, + &time_filters, + password, ) } else { - create_archive_file(|| Ok(io::stdout().lock()), creation_context, target_items) + create_archive_file( + || Ok(io::stdout().lock()), + creation_context, + target_items, + &filter, + &time_filters, + password, + ) } } @@ -989,6 +1002,9 @@ fn run_append(args: StdioCommand) -> anyhow::Result<()> { if let Some(working_dir) = args.working_dir { env::set_current_dir(working_dir)?; } + // Parse sources AFTER changing directory so @archive paths are affected by -C + let sources = ItemSource::parse_many(&files); + validate_no_duplicate_stdin(&sources)?; let collect_options = CollectOptions { recursive: args.recursive, keep_dir: args.keep_dir, @@ -1003,10 +1019,17 @@ fn run_append(args: StdioCommand) -> anyhow::Result<()> { let mut resolver = HardlinkResolver::new(collect_options.follow_links); if let Some(file) = &archive_path { let archive = open_archive_then_seek_to_end(file)?; - let target_items = collect_items_from_paths(&files, &collect_options, &mut resolver)?; - run_append_archive(&create_options, archive, target_items) + let target_items = collect_items_from_sources(sources, &collect_options, &mut resolver)?; + run_append_archive( + &create_options, + archive, + target_items, + &filter, + &time_filters, + password, + ) } else { - let target_items = collect_items_from_paths(&files, &collect_options, &mut resolver)?; + let target_items = collect_items_from_sources(sources, &collect_options, &mut resolver)?; let mut output_archive = Archive::write_header(io::stdout().lock())?; { let mut input_archive = Archive::read_header(io::stdin().lock())?; @@ -1014,7 +1037,14 @@ fn run_append(args: StdioCommand) -> anyhow::Result<()> { output_archive.add_entry(entry?)?; } } - run_append_archive(&create_options, output_archive, target_items) + run_append_archive( + &create_options, + output_archive, + target_items, + &filter, + &time_filters, + password, + ) } } diff --git a/cli/src/command/update.rs b/cli/src/command/update.rs index e353bf1d5..b58e3cc45 100644 --- a/cli/src/command/update.rs +++ b/cli/src/command/update.rs @@ -10,8 +10,8 @@ use crate::{ command::{ Command, ask_password, check_password, core::{ - AclStrategy, CollectOptions, CollectedItem, CreateOptions, FflagsStrategy, KeepOptions, - MacMetadataStrategy, PathFilter, PathTransformers, PathnameEditor, + AclStrategy, CollectOptions, CollectedEntry, CreateOptions, FflagsStrategy, + KeepOptions, MacMetadataStrategy, PathFilter, PathTransformers, PathnameEditor, PermissionStrategyResolver, TimeFilterResolver, TimestampStrategyResolver, TransformStrategy, TransformStrategyKeepSolid, TransformStrategyUnSolid, XattrStrategy, collect_items_from_paths, collect_split_archives, create_entry, entry_option, @@ -523,7 +523,7 @@ pub(crate) fn run_update_archive( archives: impl IntoIterator + Send, password: Option<&[u8]>, create_options: &CreateOptions, - target_items: Vec, + target_items: Vec, sync: bool, out_archive: &mut Archive, _strategy: Strategy, @@ -596,7 +596,7 @@ pub(crate) fn run_update_archive<'d, Strategy, W>( archives: impl IntoIterator + Send, password: Option<&[u8]>, create_options: &CreateOptions, - target_items: Vec, + target_items: Vec, sync: bool, out_archive: &mut Archive, _strategy: Strategy, diff --git a/cli/tests/cli/stdio.rs b/cli/tests/cli/stdio.rs index 0d77b1f1b..74f3f9fc1 100644 --- a/cli/tests/cli/stdio.rs +++ b/cli/tests/cli/stdio.rs @@ -1,3 +1,4 @@ +mod archive_inclusion; mod exclude_vcs; mod files_from; mod option_auto_compress; diff --git a/cli/tests/cli/stdio/archive_inclusion.rs b/cli/tests/cli/stdio/archive_inclusion.rs new file mode 100644 index 000000000..4d488ae2a --- /dev/null +++ b/cli/tests/cli/stdio/archive_inclusion.rs @@ -0,0 +1,743 @@ +use crate::utils::{archive::for_each_entry, setup}; +use assert_cmd::cargo::cargo_bin_cmd; +use pna::{Archive, EntryBuilder, WriteOptions}; +use std::collections::HashSet; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +fn create_test_archive(path: &Path, entries: &[(&str, &str)]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let file = fs::File::create(path).unwrap(); + let mut writer = Archive::write_header(file).unwrap(); + for (name, contents) in entries { + writer + .add_entry({ + let mut builder = + EntryBuilder::new_file((*name).into(), WriteOptions::builder().build()) + .unwrap(); + builder.write_all(contents.as_bytes()).unwrap(); + builder.build().unwrap() + }) + .unwrap(); + } + writer.finalize().unwrap(); +} + +fn get_archive_entry_names(path: &Path) -> Vec { + let mut names = Vec::new(); + for_each_entry(path, |entry| { + names.push(entry.header().path().to_string()); + }) + .unwrap(); + names +} + +/// Test basic @archive inclusion: create a new archive including entries from an existing archive. +#[test] +fn stdio_archive_inclusion_basic() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_basic"); + fs::create_dir_all(&base).unwrap(); + + // Create source archive with some files + let source_archive = base.join("source.pna"); + create_test_archive( + &source_archive, + &[ + ("old_file1.txt", "old content 1"), + ("old_file2.txt", "old content 2"), + ], + ); + + // Create new file to include + let new_file = base.join("new_file.txt"); + fs::write(&new_file, "new content").unwrap(); + + // Create archive including @source.pna and new_file.txt + // Note: @source.pna is relative to -C directory, just like other positional arguments + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "-C", + base.to_str().unwrap(), + "new_file.txt", + "@source.pna", + ]) + .assert() + .success(); + + // Verify the output archive contains all entries + let entry_names: HashSet = get_archive_entry_names(&output_archive) + .into_iter() + .collect(); + assert!(entry_names.contains("new_file.txt"), "Missing new_file.txt"); + assert!( + entry_names.contains("old_file1.txt"), + "Missing old_file1.txt" + ); + assert!( + entry_names.contains("old_file2.txt"), + "Missing old_file2.txt" + ); + assert_eq!(entry_names.len(), 3); +} + +/// Test multiple @archive inclusions from different source archives. +#[test] +fn stdio_archive_inclusion_multiple() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_multiple"); + fs::create_dir_all(&base).unwrap(); + + // Create first source archive + let source1 = base.join("source1.pna"); + create_test_archive(&source1, &[("from_source1.txt", "content 1")]); + + // Create second source archive + let source2 = base.join("source2.pna"); + create_test_archive(&source2, &[("from_source2.txt", "content 2")]); + + // Create new file + let new_file = base.join("new.txt"); + fs::write(&new_file, "new").unwrap(); + + // Create archive including both source archives + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "-C", + base.to_str().unwrap(), + "new.txt", + "@source1.pna", + "@source2.pna", + ]) + .assert() + .success(); + + // Verify all entries present + let entry_names: HashSet = get_archive_entry_names(&output_archive) + .into_iter() + .collect(); + assert!(entry_names.contains("new.txt")); + assert!(entry_names.contains("from_source1.txt")); + assert!(entry_names.contains("from_source2.txt")); + assert_eq!(entry_names.len(), 3); +} + +/// Test @archive inclusion with solid mode enabled. +#[test] +fn stdio_archive_inclusion_solid() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_solid"); + fs::create_dir_all(&base).unwrap(); + + // Create source archive + let source_archive = base.join("source.pna"); + create_test_archive( + &source_archive, + &[("source_file.txt", "source content here")], + ); + + // Create new file + let new_file = base.join("new_file.txt"); + fs::write(&new_file, "new content here").unwrap(); + + // Create archive with --solid + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "--solid", + "-f", + output_archive.to_str().unwrap(), + "-C", + base.to_str().unwrap(), + "new_file.txt", + "@source.pna", + ]) + .assert() + .success(); + + // Verify entries in output archive + let entry_names: HashSet = get_archive_entry_names(&output_archive) + .into_iter() + .collect(); + assert!(entry_names.contains("new_file.txt")); + assert!(entry_names.contains("source_file.txt")); + assert_eq!(entry_names.len(), 2); + + // Verify extraction works + let extract_dir = base.join("extract"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--extract", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "--out-dir", + extract_dir.to_str().unwrap(), + ]) + .assert() + .success(); + + assert_eq!( + fs::read_to_string(extract_dir.join("new_file.txt")).unwrap(), + "new content here" + ); + assert_eq!( + fs::read_to_string(extract_dir.join("source_file.txt")).unwrap(), + "source content here" + ); +} + +/// Test @archive inclusion in append mode. +#[test] +fn stdio_archive_inclusion_append() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_append"); + fs::create_dir_all(&base).unwrap(); + + // Create initial archive + let archive = base.join("archive.pna"); + create_test_archive(&archive, &[("initial.txt", "initial content")]); + + // Create source archive to include + let source_archive = base.join("source.pna"); + create_test_archive(&source_archive, &[("from_source.txt", "source content")]); + + // Append entries from source archive + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--append", + "--unstable", + "-f", + archive.to_str().unwrap(), + "-C", + base.to_str().unwrap(), + "@source.pna", + ]) + .assert() + .success(); + + // Verify all entries present + let entry_names: HashSet = get_archive_entry_names(&archive).into_iter().collect(); + assert!(entry_names.contains("initial.txt")); + assert!(entry_names.contains("from_source.txt")); + assert_eq!(entry_names.len(), 2); +} + +/// Test that @archive with non-existent file produces an error. +#[test] +fn stdio_archive_inclusion_nonexistent() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_nonexistent"); + fs::create_dir_all(&base).unwrap(); + + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "@nonexistent_archive.pna", + ]) + .assert() + .failure(); +} + +/// Test that creating archive with @- when also outputting to stdout produces an error. +#[test] +fn stdio_archive_inclusion_stdin_stdout_conflict() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_stdin_stdout_conflict"); + fs::create_dir_all(&base).unwrap(); + + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "-f", + "-", + "@-", + ]) + .assert() + .failure(); +} + +/// Test @archive inclusion preserves entry data correctly. +#[test] +fn stdio_archive_inclusion_data_integrity() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_data_integrity"); + fs::create_dir_all(&base).unwrap(); + + // Create source archive with larger content + let source_archive = base.join("source.pna"); + let large_content = "x".repeat(10000); + create_test_archive(&source_archive, &[("large_file.txt", &large_content)]); + + // Create archive including source + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "-C", + base.to_str().unwrap(), + "@source.pna", + ]) + .assert() + .success(); + + // Extract and verify content + let extract_dir = base.join("extract"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--extract", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "--out-dir", + extract_dir.to_str().unwrap(), + ]) + .assert() + .success(); + + assert_eq!( + fs::read_to_string(extract_dir.join("large_file.txt")).unwrap(), + large_content + ); +} + +/// Precondition: Source archive contains files with different extensions. +/// Action: Create archive with `--exclude` pattern and `@archive` inclusion. +/// Expectation: Entries matching the exclude pattern are filtered out from the included archive. +#[test] +fn stdio_archive_inclusion_exclude_filter() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_exclude_filter"); + fs::create_dir_all(&base).unwrap(); + + // Create source archive with various file types + let source_archive = base.join("source.pna"); + create_test_archive( + &source_archive, + &[ + ("keep.txt", "keep this"), + ("exclude.log", "exclude this"), + ("also_keep.txt", "also keep"), + ("also_exclude.log", "also exclude"), + ], + ); + + // Create archive with --exclude='*.log' + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "--exclude=*.log", + "-C", + base.to_str().unwrap(), + "@source.pna", + ]) + .assert() + .success(); + + // Verify only .txt files are included + let entry_names: HashSet = get_archive_entry_names(&output_archive) + .into_iter() + .collect(); + assert!(entry_names.contains("keep.txt"), "Missing keep.txt"); + assert!( + entry_names.contains("also_keep.txt"), + "Missing also_keep.txt" + ); + assert!( + !entry_names.contains("exclude.log"), + "exclude.log should be filtered" + ); + assert!( + !entry_names.contains("also_exclude.log"), + "also_exclude.log should be filtered" + ); + assert_eq!(entry_names.len(), 2); +} + +/// Precondition: Source archive contains files with various names. +/// Action: Create archive with `--include` pattern and `@archive` inclusion. +/// Expectation: Only entries matching the include pattern are included from the source archive. +#[test] +fn stdio_archive_inclusion_include_filter() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_include_filter"); + fs::create_dir_all(&base).unwrap(); + + // Create source archive with various files + let source_archive = base.join("source.pna"); + create_test_archive( + &source_archive, + &[ + ("foo.txt", "foo content"), + ("bar.txt", "bar content"), + ("foobar.txt", "foobar content"), + ("baz.txt", "baz content"), + ], + ); + + // Create archive with --include='*foo*' + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "--include=*foo*", + "-C", + base.to_str().unwrap(), + "@source.pna", + ]) + .assert() + .success(); + + // Verify only files matching *foo* are included + let entry_names: HashSet = get_archive_entry_names(&output_archive) + .into_iter() + .collect(); + assert!(entry_names.contains("foo.txt"), "Missing foo.txt"); + assert!(entry_names.contains("foobar.txt"), "Missing foobar.txt"); + assert!( + !entry_names.contains("bar.txt"), + "bar.txt should be filtered" + ); + assert!( + !entry_names.contains("baz.txt"), + "baz.txt should be filtered" + ); + assert_eq!(entry_names.len(), 2); +} + +/// Precondition: Source archive contains entries; filesystem files exist. +/// Action: Create archive with `@archive` as the first argument, followed by filesystem files. +/// Expectation: Entry order is preserved: archive entries first, then filesystem files. +#[test] +fn stdio_archive_inclusion_archive_first() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_archive_first"); + fs::create_dir_all(&base).unwrap(); + + // Create source archive with entries + let source_archive = base.join("source.pna"); + create_test_archive(&source_archive, &[("from_archive.txt", "archive content")]); + + // Create filesystem file + fs::write(base.join("after.txt"), "after content").unwrap(); + + // Create archive with @archive FIRST, then filesystem file + // Note: -C affects all subsequent positional arguments including @archive + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "-C", + base.to_str().unwrap(), + "@source.pna", + "after.txt", + ]) + .assert() + .success(); + + // Verify entry ordering: archive entry first, then filesystem file + let entry_names = get_archive_entry_names(&output_archive); + assert_eq!(entry_names.len(), 2, "Expected 2 entries"); + assert_eq!( + entry_names[0], "from_archive.txt", + "First entry should be from_archive.txt (from @archive)" + ); + assert_eq!( + entry_names[1], "after.txt", + "Second entry should be after.txt (filesystem file)" + ); +} + +/// Precondition: Source archive contains zero entries. +/// Action: Create archive with empty `@archive` inclusion and a filesystem file. +/// Expectation: Output archive contains only the filesystem file; no error occurs. +#[test] +fn stdio_archive_inclusion_empty_archive() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_empty_archive"); + fs::create_dir_all(&base).unwrap(); + + // Create empty source archive (no entries) + let source_archive = base.join("empty.pna"); + create_test_archive(&source_archive, &[]); + + // Create filesystem file + fs::write(base.join("file.txt"), "content").unwrap(); + + // Create archive including empty @archive and a file + // -C is placed before @empty.pna so the archive path is relative to base + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "-C", + base.to_str().unwrap(), + "@empty.pna", + "file.txt", + ]) + .assert() + .success(); + + // Verify output contains only the filesystem file + let entry_names: HashSet = get_archive_entry_names(&output_archive) + .into_iter() + .collect(); + assert!(entry_names.contains("file.txt"), "Missing file.txt"); + assert_eq!(entry_names.len(), 1, "Should contain exactly 1 entry"); +} + +/// Precondition: Source archive contains files matching various patterns. +/// Action: Create archive with both `--include` and `--exclude` patterns and `@archive` inclusion. +/// Expectation: Exclude takes precedence; only entries matching include but not exclude are included. +#[test] +fn stdio_archive_inclusion_combined_filters() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_combined_filters"); + fs::create_dir_all(&base).unwrap(); + + // Create source archive with files that match different filter combinations + let source_archive = base.join("source.pna"); + create_test_archive( + &source_archive, + &[ + ("foo.txt", "matches include, not excluded"), + ("bar.txt", "does not match include"), + ("foobar.log", "matches include, but excluded by *.log"), + ("baz.log", "does not match include, excluded"), + ], + ); + + // Create archive with --include='*foo*' --exclude='*.log' + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "--include=*foo*", + "--exclude=*.log", + "-C", + base.to_str().unwrap(), + "@source.pna", + ]) + .assert() + .success(); + + // Verify: only foo.txt should be included + // - foo.txt: matches *foo*, not *.log → INCLUDED + // - bar.txt: doesn't match *foo* → EXCLUDED + // - foobar.log: matches *foo*, but also *.log → EXCLUDED (exclude wins) + // - baz.log: doesn't match *foo*, matches *.log → EXCLUDED + let entry_names: HashSet = get_archive_entry_names(&output_archive) + .into_iter() + .collect(); + assert!( + entry_names.contains("foo.txt"), + "foo.txt should be included" + ); + assert!( + !entry_names.contains("bar.txt"), + "bar.txt should be excluded (doesn't match include)" + ); + assert!( + !entry_names.contains("foobar.log"), + "foobar.log should be excluded (exclude takes precedence)" + ); + assert!( + !entry_names.contains("baz.log"), + "baz.log should be excluded" + ); + assert_eq!(entry_names.len(), 1, "Should contain exactly 1 entry"); +} + +/// Precondition: Source archive contains files with different extensions. +/// Action: Create solid archive with `--exclude` pattern and `@archive` inclusion. +/// Expectation: Entries matching exclude pattern are filtered before solid repack. +#[test] +fn stdio_archive_inclusion_solid_with_filter() { + setup(); + + let base = PathBuf::from("stdio_archive_inclusion_solid_with_filter"); + fs::create_dir_all(&base).unwrap(); + + // Create source archive with various file types + let source_archive = base.join("source.pna"); + create_test_archive( + &source_archive, + &[ + ("keep.txt", "keep this content"), + ("exclude.log", "exclude this"), + ("also_keep.txt", "also keep this"), + ], + ); + + // Create solid archive with --exclude='*.log' + let output_archive = base.join("output.pna"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--create", + "--unstable", + "--overwrite", + "--solid", + "-f", + output_archive.to_str().unwrap(), + "--exclude=*.log", + "-C", + base.to_str().unwrap(), + "@source.pna", + ]) + .assert() + .success(); + + // Verify only .txt files are included in solid archive + let entry_names: HashSet = get_archive_entry_names(&output_archive) + .into_iter() + .collect(); + assert!(entry_names.contains("keep.txt"), "Missing keep.txt"); + assert!( + entry_names.contains("also_keep.txt"), + "Missing also_keep.txt" + ); + assert!( + !entry_names.contains("exclude.log"), + "exclude.log should be filtered" + ); + assert_eq!(entry_names.len(), 2, "Should contain exactly 2 entries"); + + // Verify extraction works and data is correct + let extract_dir = base.join("extract"); + cargo_bin_cmd!("pna") + .args([ + "--quiet", + "experimental", + "stdio", + "--extract", + "--unstable", + "--overwrite", + "-f", + output_archive.to_str().unwrap(), + "--out-dir", + extract_dir.to_str().unwrap(), + ]) + .assert() + .success(); + + assert_eq!( + fs::read_to_string(extract_dir.join("keep.txt")).unwrap(), + "keep this content" + ); + assert_eq!( + fs::read_to_string(extract_dir.join("also_keep.txt")).unwrap(), + "also keep this" + ); + assert!( + !extract_dir.join("exclude.log").exists(), + "exclude.log should not be extracted" + ); +}