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
162 changes: 152 additions & 10 deletions lib/src/entry/name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,7 @@ pub struct EntryName(String);

impl EntryName {
fn new_from_utf8path(path: &Utf8Path) -> Self {
let path = normalize_utf8path(path);
let iter = path.components().filter_map(|c| match c {
Utf8Component::Prefix(_)
| Utf8Component::RootDir
| Utf8Component::CurDir
| Utf8Component::ParentDir => None,
Utf8Component::Normal(p) => Some(p),
});
Self(join_with_capacity(iter, "/", path.as_str().len()))
Self(path.as_str().to_owned()).sanitize()
}

#[inline]
Expand Down Expand Up @@ -71,7 +63,101 @@ impl EntryName {

#[inline]
fn from_path_lossy(p: &Path) -> Self {
Self::new_from_utf8(&p.to_string_lossy())
Self::from_path_lossy_preserve_root(p).sanitize()
}

/// Creates an [EntryName] from a path, preserving absolute path components.
///
/// This method is similar to the `From` implementations for path-like types, but preserves absolute path components.
///
/// # Examples
///
/// ```rust
/// use libpna::EntryName;
///
/// assert_eq!("foo.txt", EntryName::from_utf8_preserve_root("foo.txt"));
/// assert_eq!("/foo.txt", EntryName::from_utf8_preserve_root("/foo.txt"));
/// assert_eq!("./foo.txt", EntryName::from_utf8_preserve_root("./foo.txt"));
/// assert_eq!("../foo.txt", EntryName::from_utf8_preserve_root("../foo.txt"));
/// assert_eq!("bar/../foo.txt", EntryName::from_utf8_preserve_root("bar/../foo.txt"));
/// ```
#[inline]
pub fn from_utf8_preserve_root(path: &str) -> Self {
Self::new_from_utf8path_preserve_root(Utf8Path::new(path))
}

#[inline]
fn new_from_utf8path_preserve_root(path: &Utf8Path) -> Self {
Self(path.as_str().to_owned())
}
Comment thread
ChanTsune marked this conversation as resolved.

/// Creates an [EntryName] from a path, preserving absolute path components.
///
/// This method is similar to the `From` implementations for path-like types, but preserves absolute path components.
///
/// # Examples
///
/// ```rust
/// use libpna::EntryName;
///
/// assert_eq!("foo.txt", EntryName::from_path_preserve_root("foo.txt".as_ref()).unwrap());
/// assert_eq!("./foo.txt", EntryName::from_path_preserve_root("./foo.txt".as_ref()).unwrap());
/// assert_eq!("bar/../foo.txt", EntryName::from_path_preserve_root("bar/../foo.txt".as_ref()).unwrap());
/// ```
///
/// # Errors
///
/// Returns [`EntryNameError`] if the path cannot be represented as valid UTF-8.
#[inline]
pub fn from_path_preserve_root(name: &Path) -> Result<Self, EntryNameError> {
Comment thread Fixed
let path = str::from_utf8(name.as_os_str().as_encoded_bytes())?;
Ok(Self::new_from_utf8path_preserve_root(Utf8Path::new(path)))
}

/// Creates an [EntryName] from a path, preserving absolute path components.
///
/// This method is similar to the `From` implementations for path-like types, but preserves absolute path components.
///
/// # Errors
///
/// Returns an [`EntryNameError`] if it cannot be represented as valid UTF-8.
///
/// # Examples
///
/// ```rust
/// use libpna::EntryName;
///
/// assert_eq!("foo.txt", EntryName::from_path_lossy_preserve_root("foo.txt".as_ref()));
/// assert_eq!("./foo.txt", EntryName::from_path_lossy_preserve_root("./foo.txt".as_ref()));
/// assert_eq!("bar/../foo.txt", EntryName::from_path_lossy_preserve_root("bar/../foo.txt".as_ref()));
/// ```
#[inline]
pub fn from_path_lossy_preserve_root(name: &Path) -> Self {
Self::new_from_utf8path_preserve_root(Utf8Path::new(&name.to_string_lossy()))
}

/// Returns a sanitized copy of this entry name that contains only normal components.
///
/// Sanitization discards prefixes, root separators, `.` and `..` segments so the
/// resulting entry name is always relative and safe to embed in an archive.
///
/// # Examples
///
/// ```rust
/// use libpna::EntryName;
///
/// let name = EntryName::from_utf8_preserve_root("/var/../tmp/./log");
/// assert_eq!("tmp/log", name.sanitize());
/// ```
#[inline]
pub fn sanitize(&self) -> Self {
let path = normalize_utf8path(Utf8Path::new(&self.0));
Self(join_with_capacity(
path.components()
.filter(|c| matches!(c, Utf8Component::Normal(_))),
"/",
path.as_str().len(),
))
}

#[inline]
Expand Down Expand Up @@ -478,6 +564,62 @@ mod tests {
assert!(EntryName::try_from(invalid_os_str).is_err());
}

#[test]
fn preserve_root_keeps_unsafe_components() {
assert_eq!(
"/../foo",
EntryName::from_utf8_preserve_root("/../foo").as_str()
);
assert_eq!(
"bar/../foo",
EntryName::from_utf8_preserve_root("bar/../foo").as_str()
);
assert_eq!(
"../foo",
EntryName::from_utf8_preserve_root("../foo").as_str()
);
}

#[test]
fn preserve_root_edge_cases() {
// Empty string
assert_eq!("", EntryName::from_utf8_preserve_root(""));
// Only parent dir
assert_eq!("..", EntryName::from_utf8_preserve_root(".."));
// Only current dir
assert_eq!(".", EntryName::from_utf8_preserve_root("."));
// Only root
assert_eq!("/", EntryName::from_utf8_preserve_root("/"));
// Multiple parent dirs
assert_eq!("../../..", EntryName::from_utf8_preserve_root("../../.."));
}

#[test]
fn sanitize_edge_cases() {
// Empty string remains empty
assert_eq!("", EntryName::from_utf8_preserve_root("").sanitize());
// Only parent dir becomes empty
assert_eq!("", EntryName::from_utf8_preserve_root("..").sanitize());
// Only current dir becomes empty
assert_eq!("", EntryName::from_utf8_preserve_root(".").sanitize());
// Only root becomes empty
assert_eq!("", EntryName::from_utf8_preserve_root("/").sanitize());
// Multiple parent dirs become empty
assert_eq!(
"",
EntryName::from_utf8_preserve_root("../../..").sanitize()
);
// Mixed with normal component
assert_eq!(
"foo",
EntryName::from_utf8_preserve_root("/../foo").sanitize()
);
assert_eq!(
"foo",
EntryName::from_utf8_preserve_root("./foo").sanitize()
);
}

#[test]
fn type_conversions() {
// Path conversions
Expand Down
163 changes: 147 additions & 16 deletions lib/src/entry/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,7 @@ pub struct EntryReference(String);

impl EntryReference {
fn new_from_utf8path(path: &Utf8Path) -> Self {
let has_root = path.has_root();
let has_prefix = path
.components()
.any(|it| matches!(&it, Utf8Component::Prefix(_)));
let p = path.components().filter_map(|it| match it {
Utf8Component::Prefix(p) => Some(p.as_str()),
Utf8Component::RootDir => None,
Utf8Component::CurDir => Some("."),
Utf8Component::ParentDir => Some(".."),
Utf8Component::Normal(n) => Some(n),
});
let mut s = join_with_capacity(p, "/", path.as_str().len());
if !has_prefix && has_root {
s.insert(0, '/');
};
Self(s)
Self::new_from_utf8path_preserve_root(path).sanitize()
}

#[inline]
Expand Down Expand Up @@ -80,6 +65,89 @@ impl EntryReference {
Self::new_from_utf8(&path.to_string_lossy())
}

/// Creates an [EntryReference] from a UTF-8 string while preserving absolute
/// roots, prefixes, and parent components.
///
/// # Examples
///
/// ```
/// use libpna::EntryReference;
///
/// assert_eq!("/foo.txt", EntryReference::from_utf8_preserve_root("/foo.txt"));
/// assert_eq!("bar/../foo.txt", EntryReference::from_utf8_preserve_root("bar/../foo.txt"));
/// assert_eq!("../foo.txt", EntryReference::from_utf8_preserve_root("../foo.txt"));
/// ```
#[inline]
pub fn from_utf8_preserve_root(path: &str) -> Self {
Self::new_from_utf8path_preserve_root(Utf8Path::new(path))
}

#[inline]
fn new_from_utf8path_preserve_root(path: &Utf8Path) -> Self {
Self(path.as_str().to_owned())
}

/// Creates an [EntryReference] from a path, preserving absolute path components.
///
/// # Errors
///
/// Returns an [`EntryReferenceError`] if the path cannot be represented as valid UTF-8.
///
/// # Examples
///
/// ```
/// use libpna::EntryReference;
///
/// assert_eq!("/foo.txt", EntryReference::from_path_preserve_root("/foo.txt".as_ref()).unwrap());
/// assert_eq!("../foo.txt", EntryReference::from_path_preserve_root("../foo.txt".as_ref()).unwrap());
/// ```
#[inline]
pub fn from_path_preserve_root(path: &Path) -> Result<Self, EntryReferenceError> {
let path = str::from_utf8(path.as_os_str().as_encoded_bytes())?;
Ok(Self::new_from_utf8path_preserve_root(Utf8Path::new(path)))
}

/// Creates an [EntryReference] from a path, preserving absolute path components.
///
/// Any invalid UTF-8 sequences are replaced.
///
/// # Examples
///
/// ```
/// use libpna::EntryReference;
///
/// assert_eq!("/foo.txt", EntryReference::from_path_lossy_preserve_root("/foo.txt".as_ref()));
/// ```
#[inline]
pub fn from_path_lossy_preserve_root(path: &Path) -> Self {
Self::new_from_utf8path_preserve_root(Utf8Path::new(&path.to_string_lossy()))
}

/// Returns a sanitized relative reference containing only normal path components.
///
/// This discards prefixes, root separators, `.` and `..`, mirroring the safety
/// behavior used for archive member names.
#[inline]
pub fn sanitize(&self) -> Self {
let path = Utf8Path::new(&self.0);
let has_root = path.has_root();
let has_prefix = path
.components()
.any(|it| matches!(&it, Utf8Component::Prefix(_)));
let p = path.components().filter_map(|it| match it {
Utf8Component::Prefix(p) => Some(p.as_str()),
Utf8Component::RootDir => None,
Utf8Component::CurDir => Some("."),
Utf8Component::ParentDir => Some(".."),
Utf8Component::Normal(n) => Some(n),
});
let mut s = join_with_capacity(p, "/", path.as_str().len());
if !has_prefix && has_root {
s.insert(0, '/');
};
Self(s)
}

#[inline]
pub(crate) fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
Expand Down Expand Up @@ -399,6 +467,23 @@ mod tests {
assert_eq!("/", EntryReference::from("///"));
}

#[test]
fn preserve_root_variants() {
assert_eq!(
"/abs/path",
EntryReference::from_utf8_preserve_root("/abs/path")
);
assert_eq!(
"../rel/path",
EntryReference::from_utf8_preserve_root("../rel/path")
);
// preserve_root stores the string as-is, no separator conversion
assert_eq!(
"C:\\drive\\path",
EntryReference::from_utf8_preserve_root("C:\\drive\\path")
);
}

#[test]
fn remove_last() {
assert_eq!("test", EntryReference::from("test/"));
Expand Down Expand Up @@ -472,6 +557,52 @@ mod tests {
assert_eq!("/test/test.txt", EntryReference::from("///test///test.txt"));
}

#[test]
fn preserve_root_edge_cases() {
// Empty string
assert_eq!("", EntryReference::from_utf8_preserve_root(""));
// Only parent dir
assert_eq!("..", EntryReference::from_utf8_preserve_root(".."));
// Only current dir
assert_eq!(".", EntryReference::from_utf8_preserve_root("."));
// Only root
assert_eq!("/", EntryReference::from_utf8_preserve_root("/"));
// Multiple parent dirs
assert_eq!(
"../../..",
EntryReference::from_utf8_preserve_root("../../..")
);
}

#[test]
fn sanitize_edge_cases() {
// Empty string remains empty
assert_eq!("", EntryReference::from_utf8_preserve_root("").sanitize());
// Only parent dir
assert_eq!(
"..",
EntryReference::from_utf8_preserve_root("..").sanitize()
);
// Only current dir
assert_eq!(".", EntryReference::from_utf8_preserve_root(".").sanitize());
// Only root
assert_eq!("/", EntryReference::from_utf8_preserve_root("/").sanitize());
// Multiple parent dirs
assert_eq!(
"../../..",
EntryReference::from_utf8_preserve_root("../../..").sanitize()
);
// Mixed with normal component
assert_eq!(
"/../foo",
EntryReference::from_utf8_preserve_root("/../foo").sanitize()
);
assert_eq!(
"./foo",
EntryReference::from_utf8_preserve_root("./foo").sanitize()
);
}

#[cfg(unix)]
#[test]
fn unix_error_cases() {
Expand Down
Loading