From 98c296893d48cad2b52016a020b17bf2141f83f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 22:00:20 +0000 Subject: [PATCH 1/3] fix: enforce consistent secure read/copy/move semantics Agent-Logs-Url: https://github.com/GhaziAlibi/softpath/sessions/7db4165a-56ca-493e-8fd4-f053a21fd429 Co-authored-by: GhaziAlibi <123127137+GhaziAlibi@users.noreply.github.com> --- README.md | 3 +++ src/lib.rs | 3 +++ src/ops/implementations/path_impl.rs | 18 ++++++++++++++++ src/ops/implementations/pathbuf_impl.rs | 18 ++++++++++++++++ src/ops/implementations/str_impl.rs | 14 +++---------- src/ops/path_ext.rs | 1 + tests/file_operations.rs | 28 +++++++++++++++++++++++++ tests/test_softpath_errors.rs | 9 ++++++++ 8 files changed, 83 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c138059..3f39d32 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ fn main() -> Result<(), softpath::SoftPathError> { // Copy it somewhere else let backup = "~/config/backup/app.json".into_path()?; + if backup.exists()? { + backup.remove()?; + } config_file.copy_to(&backup)?; // Create directories as needed diff --git a/src/lib.rs b/src/lib.rs index 613c2b2..d39db73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,6 +63,9 @@ //! //! // Copy to backup location //! let backup = backup_path.join("app.json").into_path()?; +//! if backup.exists()? { +//! backup.remove()?; +//! } //! config_file.copy_to(&backup)?; //! # Ok(()) //! # } diff --git a/src/ops/implementations/path_impl.rs b/src/ops/implementations/path_impl.rs index 916adc0..1a545d9 100644 --- a/src/ops/implementations/path_impl.rs +++ b/src/ops/implementations/path_impl.rs @@ -39,6 +39,8 @@ impl PathExt for &Path { } fn read_to_string(&self) -> Result { + crate::utils::check_path_traversal(self)?; + crate::utils::check_symlink_cycles(self)?; fs::read_to_string(self).map_err(SoftPathError::from) } @@ -47,17 +49,33 @@ impl PathExt for &Path { } fn copy_to>(&self, dest: P) -> Result<(), SoftPathError> { + crate::utils::check_path_traversal(self)?; + crate::utils::check_symlink_cycles(self)?; let dest_path = dest.as_ref(); crate::utils::check_path_traversal(dest_path)?; crate::utils::check_symlink_cycles(dest_path)?; + if dest_path.exists() { + return Err(SoftPathError::Io(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("Destination already exists: {}", dest_path.display()), + ))); + } fs::copy(self, dest)?; Ok(()) } fn move_to>(&self, dest: P) -> Result<(), SoftPathError> { + crate::utils::check_path_traversal(self)?; + crate::utils::check_symlink_cycles(self)?; let dest_path = dest.as_ref(); crate::utils::check_path_traversal(dest_path)?; crate::utils::check_symlink_cycles(dest_path)?; + if dest_path.exists() { + return Err(SoftPathError::Io(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("Destination already exists: {}", dest_path.display()), + ))); + } fs::rename(self, dest)?; Ok(()) } diff --git a/src/ops/implementations/pathbuf_impl.rs b/src/ops/implementations/pathbuf_impl.rs index 0b6456c..bcca1e4 100644 --- a/src/ops/implementations/pathbuf_impl.rs +++ b/src/ops/implementations/pathbuf_impl.rs @@ -45,6 +45,8 @@ impl PathExt for PathBuf { } fn read_to_string(&self) -> Result { + crate::utils::check_path_traversal(self)?; + crate::utils::check_symlink_cycles(self)?; fs::read_to_string(self).map_err(SoftPathError::from) } @@ -53,17 +55,33 @@ impl PathExt for PathBuf { } fn copy_to>(&self, dest: P) -> Result<(), SoftPathError> { + crate::utils::check_path_traversal(self)?; + crate::utils::check_symlink_cycles(self)?; let dest_path = dest.as_ref(); crate::utils::check_path_traversal(dest_path)?; crate::utils::check_symlink_cycles(dest_path)?; + if dest_path.exists() { + return Err(SoftPathError::Io(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("Destination already exists: {}", dest_path.display()), + ))); + } fs::copy(self, dest)?; Ok(()) } fn move_to>(&self, dest: P) -> Result<(), SoftPathError> { + crate::utils::check_path_traversal(self)?; + crate::utils::check_symlink_cycles(self)?; let dest_path = dest.as_ref(); crate::utils::check_path_traversal(dest_path)?; crate::utils::check_symlink_cycles(dest_path)?; + if dest_path.exists() { + return Err(SoftPathError::Io(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("Destination already exists: {}", dest_path.display()), + ))); + } fs::rename(self, dest)?; Ok(()) } diff --git a/src/ops/implementations/str_impl.rs b/src/ops/implementations/str_impl.rs index b1ac37b..5c35320 100644 --- a/src/ops/implementations/str_impl.rs +++ b/src/ops/implementations/str_impl.rs @@ -68,7 +68,7 @@ impl PathExt for &str { fn read_to_string(&self) -> Result { let path = self.into_path()?; - fs::read_to_string(path).map_err(SoftPathError::from) + path.read_to_string() } fn write_string(&self, contents: &str) -> Result<(), SoftPathError> { @@ -78,20 +78,12 @@ impl PathExt for &str { fn copy_to>(&self, dest: P) -> Result<(), SoftPathError> { let from = self.into_path()?; - let dest_path = dest.as_ref(); - crate::utils::check_path_traversal(dest_path)?; - crate::utils::check_symlink_cycles(dest_path)?; - fs::copy(&from, dest)?; - Ok(()) + from.copy_to(dest) } fn move_to>(&self, dest: P) -> Result<(), SoftPathError> { let from = self.into_path()?; - let dest_path = dest.as_ref(); - crate::utils::check_path_traversal(dest_path)?; - crate::utils::check_symlink_cycles(dest_path)?; - fs::rename(&from, dest)?; - Ok(()) + from.move_to(dest) } fn is_empty(&self) -> Result { diff --git a/src/ops/path_ext.rs b/src/ops/path_ext.rs index ce40e78..42ecf71 100644 --- a/src/ops/path_ext.rs +++ b/src/ops/path_ext.rs @@ -111,6 +111,7 @@ pub trait PathExt { /// - The source path does not exist /// - The destination path already exists /// - The user lacks permissions + /// - Any parent directories of the destination are missing fn move_to>(&self, dest: P) -> Result<(), SoftPathError>; /// Returns true if the path points to an empty file or directory. diff --git a/tests/file_operations.rs b/tests/file_operations.rs index 34c8739..faa9a64 100644 --- a/tests/file_operations.rs +++ b/tests/file_operations.rs @@ -59,6 +59,34 @@ fn test_copy_and_move() { cleanup_test_dir(&test_dir); } +#[test] +fn test_copy_and_move_reject_existing_destination() -> Result<(), SoftPathError> { + let test_dir = setup_test_dir(); + + let source = test_dir.join("source.txt"); + source.write_string("source content")?; + + let existing_copy_dest = test_dir.join("existing_copy.txt"); + existing_copy_dest.write_string("existing content")?; + let copy_err = source.copy_to(&existing_copy_dest).unwrap_err(); + assert!(matches!( + copy_err, + SoftPathError::Io(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists + )); + + let existing_move_dest = test_dir.join("existing_move.txt"); + existing_move_dest.write_string("existing content")?; + let move_err = source.move_to(&existing_move_dest).unwrap_err(); + assert!(matches!( + move_err, + SoftPathError::Io(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists + )); + assert!(source.exists()?); + + cleanup_test_dir(&test_dir); + Ok(()) +} + #[test] fn test_is_empty() -> Result<(), SoftPathError> { let test_dir = setup_test_dir(); diff --git a/tests/test_softpath_errors.rs b/tests/test_softpath_errors.rs index 9264a85..64db296 100644 --- a/tests/test_softpath_errors.rs +++ b/tests/test_softpath_errors.rs @@ -125,6 +125,15 @@ fn test_io_errors() { } } +#[test] +fn test_read_to_string_path_traversal_error() { + let malicious_path = "../../../../../../../../../etc/passwd"; + assert!(matches!( + malicious_path.read_to_string(), + Err(SoftPathError::PathTraversal(_)) + )); +} + #[test] fn test_error_matching_patterns() { let malicious_path = "../../../../../../../../../etc/passwd"; From c3c3a2739649539d161350d6cc2a49bf8c9d11aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 22:07:52 +0000 Subject: [PATCH 2/3] chore: bump version to 0.3.0 Agent-Logs-Url: https://github.com/GhaziAlibi/softpath/sessions/cf3e7309-c5c7-40a5-85af-529908a69b40 Co-authored-by: GhaziAlibi <123127137+GhaziAlibi@users.noreply.github.com> --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69365cc..aa40301 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,7 +562,7 @@ dependencies = [ [[package]] name = "softpath" -version = "0.2.2" +version = "0.3.0" dependencies = [ "criterion", "dirs", diff --git a/Cargo.toml b/Cargo.toml index d0566f7..c523846 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "softpath" -version = "0.2.2" +version = "0.3.0" edition = "2021" authors = ["ALIBI Ghazu "] description = "A human-friendly file and directory path manipulation library for Rust." From 8c3eaa78b99539aea32d7f65603ca6a6f6712c02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 22:36:42 +0000 Subject: [PATCH 3/3] chore: fix author name spelling in Cargo.toml Agent-Logs-Url: https://github.com/GhaziAlibi/softpath/sessions/46c4f7f5-2b5a-41bc-a85c-a588dbc1a029 Co-authored-by: GhaziAlibi <123127137+GhaziAlibi@users.noreply.github.com> --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c523846..a13567b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "softpath" version = "0.3.0" edition = "2021" -authors = ["ALIBI Ghazu "] +authors = ["ALIBI Ghazi "] description = "A human-friendly file and directory path manipulation library for Rust." license = "MIT" repository = "https://github.com/GhaziAlibi/softpath"