Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/src/command/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
cli::{CipherAlgorithmArgs, CompressionAlgorithmArgs, HashAlgorithmArgs, MissingTimePolicy},
utils::{self, PathPartExt, fs::HardlinkResolver},
};
use anyhow::Context;

Check warning on line 25 in cli/src/command/core.rs

View workflow job for this annotation

GitHub Actions / Test WebAssembly (nightly, wasm32-wasip1)

unused import: `anyhow::Context`
pub(crate) use iter::ReorderByIndex;
pub(crate) use path_filter::PathFilter;
use path_slash::*;
Expand All @@ -42,7 +42,7 @@

/// Detected format of an @archive source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SourceFormat {

Check warning on line 45 in cli/src/command/core.rs

View workflow job for this annotation

GitHub Actions / Test WebAssembly (nightly, wasm32-wasip1)

enum `SourceFormat` is never used
/// PNA archive format (detected by magic bytes)
Pna,
/// mtree manifest format (text-based)
Expand All @@ -53,7 +53,7 @@
///
/// Returns `SourceFormat::Pna` if the data starts with PNA magic bytes,
/// otherwise returns `SourceFormat::Mtree`.
pub(crate) fn detect_format<R: io::BufRead>(reader: &mut R) -> io::Result<SourceFormat> {

Check warning on line 56 in cli/src/command/core.rs

View workflow job for this annotation

GitHub Actions / Test WebAssembly (nightly, wasm32-wasip1)

function `detect_format` is never used
let buf = reader.fill_buf()?;

Ok(if buf.starts_with(PNA_HEADER) {
Expand Down Expand Up @@ -851,11 +851,13 @@
metadata,
} = item;
let Some(entry_name) = pathname_editor.edit_entry_name(path) else {
log::debug!("Skip: {}", path.display());
return Ok(None);
};
match store_as {
StoreAs::Hardlink(source) => {
let Some(reference) = pathname_editor.edit_hardlink(source) else {
log::debug!("Skip: {}", path.display());
return Ok(None);
};
let entry = EntryBuilder::new_hard_link(entry_name, reference)?;
Expand Down Expand Up @@ -1688,7 +1690,7 @@
// 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
log::debug!("Skip: {original_name}");
return Ok(None);
};

Expand Down
61 changes: 39 additions & 22 deletions cli/src/command/core/path.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use pna::{EntryName, EntryReference};
use std::{
borrow::Cow,
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
};

use super::PathTransformers;
Expand All @@ -19,11 +19,6 @@ pub(crate) struct PathnameEditor {
}

impl PathnameEditor {
/// Create a PathnameEditor with the given configuration.
///
/// `strip_components` sets the number of leading path components to remove when editing paths (use `None` to disable stripping).
/// `transformers` supplies optional path transformations to apply before stripping.
/// `absolute_paths` controls whether absolute paths are preserved (true) or sanitized (false).
#[inline]
pub(crate) const fn new(
strip_components: Option<usize>,
Expand All @@ -37,17 +32,24 @@ impl PathnameEditor {
}
}

/// Edit the pathname for a regular archive entry using the editor's configured transformers, strip count, and absolute-path policy.
/// Edit the pathname for a regular archive entry.
///
/// The method applies any path transformations first (bsdtar order), then strips the configured number of leading components; if stripping removes the entire path, the entry is skipped. The resulting path is converted to an `EntryName` that preserves a root component, and is sanitized unless the editor is configured to preserve absolute paths.
/// Returns `None` (skip the entry) when the path becomes empty after
/// transformation or after stripping.
pub(crate) fn edit_entry_name(&self, path: &Path) -> Option<EntryName> {
// bsdtar order: substitution first, then strip
let transformed: Cow<'_, Path> = if let Some(t) = &self.transformers {
Cow::Owned(PathBuf::from(t.apply(path.to_string_lossy(), false, false)))
} else {
Cow::Borrowed(path)
};
if is_effectively_empty_path(&transformed) {
return None;
}
let stripped = strip_components(&transformed, self.strip_components)?;
if is_effectively_empty_path(&stripped) {
return None;
}
let entry_name = EntryName::from_path_lossy_preserve_root(&stripped);
if self.absolute_paths {
Some(entry_name)
Expand All @@ -56,19 +58,10 @@ impl PathnameEditor {
}
}

/// Edit a hardlink target pathname according to the editor's configuration.
///
/// The method applies any configured path transformers, then strips the configured
/// number of leading components (if set). The resulting path is converted to an
/// `EntryReference` (preserving a leading root if present). If the editor is
/// configured to preserve absolute paths, the preserved `EntryReference` is
/// returned; otherwise a sanitized `EntryReference` is returned. If stripping
/// removes all components, `None` is returned to indicate the entry should be skipped.
/// Edit a hardlink target pathname.
///
/// # Returns
///
/// `Some(EntryReference)` with the edited reference, or `None` if the path was
/// stripped away (entry should be skipped).
/// Returns `None` (skip the entry) when the target becomes empty after
/// transformation or after stripping.
pub(crate) fn edit_hardlink(&self, target: &Path) -> Option<EntryReference> {
// bsdtar order: substitution first, then strip
let transformed: Cow<'_, Path> = if let Some(t) = &self.transformers {
Expand All @@ -80,7 +73,13 @@ impl PathnameEditor {
} else {
Cow::Borrowed(target)
};
if is_effectively_empty_path(&transformed) {
return None;
}
let stripped = strip_components(&transformed, self.strip_components)?;
if is_effectively_empty_path(&stripped) {
return None;
}
let entry_reference = EntryReference::from_path_lossy_preserve_root(&stripped);
if self.absolute_paths {
Some(entry_reference)
Expand All @@ -89,9 +88,11 @@ impl PathnameEditor {
}
}

/// Edit a symlink target path by applying any configured transformations and then either preserving or sanitizing absolute paths.
/// Edit a symlink target path.
///
/// Strip-component rules are not applied to symlink targets; only the editor's optional transformers and its absolute-paths configuration affect the result.
/// Unlike entry names and hardlink targets, symlink targets are never
/// skipped: the containing entry's name is validated separately via
/// `edit_entry_name`, and bsdtar does not strip or skip symlink targets.
pub(crate) fn edit_symlink(&self, target: &Path) -> EntryReference {
// Note: symlinks do not have strip_components applied (matching bsdtar behavior)
let transformed: Cow<'_, Path> = if let Some(t) = &self.transformers {
Expand Down Expand Up @@ -126,6 +127,11 @@ fn strip_components(path: &Path, count: Option<usize>) -> Option<Cow<'_, Path>>
Some(Cow::from(PathBuf::from_iter(components.skip(count))))
}

#[inline]
fn is_effectively_empty_path(path: &Path) -> bool {
path.components().all(|c| matches!(c, Component::CurDir))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -215,4 +221,15 @@ mod tests {
let result = editor.edit_symlink(Path::new("a/b/c"));
assert_eq!(result.as_str(), "a/b/c"); // Not stripped
}

#[test]
fn editor_skips_empty_or_curdir_paths() {
let editor = PathnameEditor::new(None, None, false);
assert!(editor.edit_entry_name(Path::new("")).is_none());
assert!(editor.edit_entry_name(Path::new(".")).is_none());
assert!(editor.edit_entry_name(Path::new("./.")).is_none());
assert!(editor.edit_hardlink(Path::new("")).is_none());
assert!(editor.edit_hardlink(Path::new(".")).is_none());
assert!(editor.edit_hardlink(Path::new("./.")).is_none());
}
Comment thread
ChanTsune marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions cli/src/command/stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,9 @@ fn run_create_archive(args: StdioCommand) -> anyhow::Result<()> {
if let Some(path) = args.files_from {
files.extend(read_paths(path, args.null)?);
}
if files.is_empty() {
anyhow::bail!("create mode requires at least one input path or @archive source");
}

let mut exclude = args.exclude;
if let Some(p) = args.exclude_from {
Expand Down
2 changes: 2 additions & 0 deletions cli/tests/cli/stdio.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod archive_inclusion;
mod exclude_vcs;
mod files_from;
mod missing_file;
#[cfg(unix)]
mod mtree;
mod option_auto_compress;
Expand All @@ -10,6 +11,7 @@ mod option_mac_metadata;
mod option_no_recursive;
mod option_options;
mod option_same_permissions;
mod option_substitution;
mod option_update;
mod strip_components;
mod unlink_first;
22 changes: 22 additions & 0 deletions cli/tests/cli/stdio/missing_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use crate::utils::setup;
use assert_cmd::cargo::cargo_bin_cmd;

/// Precondition: No input paths are provided.
/// Action: Run `pna experimental stdio -c -f ...` without positional paths.
/// Expectation: Command fails similarly to bsdtar's "missing file" handling.
#[test]
fn stdio_create_without_inputs_fails() {
setup();

let mut cmd = cargo_bin_cmd!("pna");
cmd.args([
"--quiet",
"experimental",
"stdio",
"--unstable",
"-c",
"-f",
"stdio_create_without_inputs_fails.pna",
]);
cmd.assert().failure();
}
46 changes: 46 additions & 0 deletions cli/tests/cli/stdio/option_substitution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use crate::utils::setup;
use assert_cmd::cargo::cargo_bin_cmd;
use std::fs;

/// Precondition: A single file is archived with a substitution rule that yields an empty pathname.
/// Action: Create and list archive via stdio with `-s ,in/d1/foo,,`.
/// Expectation: Entry is skipped (no blank pathname entry appears in list output).
#[test]
fn stdio_substitution_empty_name_is_skipped() {
setup();
fs::create_dir_all("stdio_substitution_empty_name_is_skipped/in/d1").unwrap();
fs::write("stdio_substitution_empty_name_is_skipped/in/d1/foo", b"foo").unwrap();

let mut create = cargo_bin_cmd!("pna");
create
.args([
"--quiet",
"experimental",
"stdio",
"--unstable",
"-c",
"-f",
"stdio_substitution_empty_name_is_skipped/archive.pna",
"-C",
"stdio_substitution_empty_name_is_skipped",
"-s",
",in/d1/foo,,",
"in/d1/foo",
])
.assert()
.success();

let mut list = cargo_bin_cmd!("pna");
list.args([
"--quiet",
"experimental",
"stdio",
"--unstable",
"-t",
"-f",
"stdio_substitution_empty_name_is_skipped/archive.pna",
])
.assert()
.success()
.stdout("");
}
Loading