diff --git a/tests/absolutize_contracts.rs b/tests/absolutize_contracts.rs new file mode 100644 index 0000000..b5c888a --- /dev/null +++ b/tests/absolutize_contracts.rs @@ -0,0 +1,153 @@ +#![cfg(any(target_family = "unix", target_family = "windows"))] + +#[cfg(target_family = "windows")] +use std::path::PathBuf; +use std::{borrow::Cow, env, path::Path}; + +use sugar_path::SugarPath; + +#[derive(Clone, Copy)] +enum ExpectedCow { + Borrowed, + Owned, +} + +fn assert_result( + result: Cow<'_, Path>, + input: &Path, + expected: &Path, + expected_cow: ExpectedCow, + context: &str, +) { + assert_eq!(result.as_os_str(), expected.as_os_str(), "{context}"); + match (expected_cow, result) { + (ExpectedCow::Borrowed, Cow::Borrowed(result)) => { + assert!(std::ptr::eq(result, input), "{context}: result did not borrow the receiver"); + } + (ExpectedCow::Owned, Cow::Owned(_)) => {} + (ExpectedCow::Borrowed, Cow::Owned(_)) => panic!("{context}: expected borrowed result"), + (ExpectedCow::Owned, Cow::Borrowed(_)) => panic!("{context}: expected owned result"), + } +} + +fn assert_ambient_case(input: &Path, expected: &Path, expected_cow: ExpectedCow, name: &str) { + assert_result(input.absolutize(), input, expected, expected_cow, &format!("{name} strict")); + assert_result( + input.try_absolutize().expect("fixture should resolve against cwd"), + input, + expected, + expected_cow, + &format!("{name} try"), + ); +} + +fn assert_string_receiver(input: String, expected: &Path) { + let strict = input.absolutize(); + assert_eq!(strict.as_os_str(), expected.as_os_str()); + assert!(matches!(strict, Cow::Owned(_))); + + let fallible = input.try_absolutize().expect("String fixture should resolve against cwd"); + assert_eq!(fallible.as_os_str(), expected.as_os_str()); + assert!(matches!(fallible, Cow::Owned(_))); +} + +#[cfg(target_family = "unix")] +#[test] +fn unix_ambient_absolutize_and_try_contract_matrix() { + let cwd = env::current_dir().expect("read current directory"); + + let clean = Path::new("/some/β/file"); + assert_ambient_case(clean, clean, ExpectedCow::Borrowed, "clean absolute"); + assert_ambient_case( + Path::new("/some/./β/../file/"), + Path::new("/some/file"), + ExpectedCow::Owned, + "dirty absolute", + ); + assert_ambient_case(Path::new(""), &cwd, ExpectedCow::Owned, "empty"); + assert_ambient_case(Path::new("."), &cwd, ExpectedCow::Owned, "dot"); + assert_ambient_case( + Path::new("./pkg//β/../file/"), + &cwd.join("pkg/file"), + ExpectedCow::Owned, + "ordinary relative", + ); + + assert_string_receiver("./owned/../file".to_owned(), &cwd.join("file")); +} + +#[cfg(target_family = "windows")] +fn current_ordinary_disk_drive(path: &Path) -> Option { + use std::path::{Component, Prefix}; + + match path.components().next() { + Some(Component::Prefix(prefix)) => match prefix.kind() { + Prefix::Disk(drive) => Some(drive), + _ => None, + }, + _ => None, + } +} + +#[cfg(target_family = "windows")] +fn preserve_drive_spelling(path: &Path, drive: u8) -> PathBuf { + use std::{ + ffi::OsString, + os::windows::ffi::{OsStrExt, OsStringExt}, + }; + + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + if wide.get(1) == Some(&(b':' as u16)) { + wide[0] = drive as u16; + } else if wide.get(5) == Some(&(b':' as u16)) + && wide.get(..4) == Some(&[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]) + { + wide[4] = drive as u16; + } else { + panic!("expected disk path, found {path:?}"); + } + PathBuf::from(OsString::from_wide(&wide)) +} + +#[cfg(target_family = "windows")] +#[test] +fn windows_ambient_absolutize_and_try_contract_matrix() { + let cwd = env::current_dir().expect("read current directory"); + + let clean = Path::new(r"c:\some\β\file"); + assert_ambient_case(clean, clean, ExpectedCow::Borrowed, "clean absolute"); + assert_ambient_case( + Path::new(r"c:/some/.\β\../file\"), + Path::new(r"c:\some\file"), + ExpectedCow::Owned, + "dirty absolute", + ); + assert_ambient_case(Path::new(""), &cwd, ExpectedCow::Owned, "empty"); + assert_ambient_case(Path::new("."), &cwd, ExpectedCow::Owned, "dot"); + assert_ambient_case( + Path::new(r".\pkg\\β\..\file\"), + &cwd.join(r"pkg\file"), + ExpectedCow::Owned, + "ordinary relative", + ); + + let root_relative = Path::new(r"\pkg\.\β\..\file\"); + let mut expected = cwd.clone(); + expected.push(r"\pkg\file"); + assert_ambient_case(root_relative, &expected, ExpectedCow::Owned, "root relative"); + + if let Some(drive) = current_ordinary_disk_drive(&cwd) { + let drive = drive.to_ascii_lowercase(); + let drive_relative = format!("{}:folder\\.\\file\\", drive as char); + let mut expected = preserve_drive_spelling(&cwd, drive); + expected.push(r"folder\file"); + assert_ambient_case( + Path::new(&drive_relative), + &expected, + ExpectedCow::Owned, + "drive relative", + ); + } + + assert_string_receiver(r".\owned\..\file".to_owned(), &cwd.join("file")); +} diff --git a/tests/absolutize_invalid_encoding.rs b/tests/absolutize_invalid_encoding.rs new file mode 100644 index 0000000..51e6921 --- /dev/null +++ b/tests/absolutize_invalid_encoding.rs @@ -0,0 +1,137 @@ +#![cfg(any(target_family = "unix", target_family = "windows"))] + +use std::{borrow::Cow, path::Path}; + +use sugar_path::SugarPath; + +#[cfg(target_family = "unix")] +mod unix { + use std::{ + env, + ffi::OsString, + os::unix::ffi::{OsStrExt, OsStringExt}, + path::PathBuf, + }; + + use super::*; + + fn path(bytes: &[u8]) -> PathBuf { + PathBuf::from(OsString::from_vec(bytes.to_vec())) + } + + fn assert_bytes(actual: &Path, expected: &Path, context: &str) { + assert_eq!(actual.as_os_str().as_bytes(), expected.as_os_str().as_bytes(), "{context}",); + } + + fn assert_ambient(input: &Path, expected: &Path, require_owned: bool, name: &str) { + let strict = input.absolutize(); + assert_bytes(&strict, expected, &format!("{name} strict")); + if require_owned { + assert!(matches!(strict, Cow::Owned(_)), "{name} strict should own"); + } + + let fallible = input.try_absolutize().expect("fixture should resolve against cwd"); + assert_bytes(&fallible, expected, &format!("{name} try")); + if require_owned { + assert!(matches!(fallible, Cow::Owned(_)), "{name} try should own"); + } + } + + #[test] + fn ambient_absolutization_preserves_invalid_unix_bytes() { + let clean = path(b"/sugar-path/invalid-\x80/file"); + assert_ambient(&clean, &clean, false, "clean absolute"); + + let dirty = path(b"/sugar-path/invalid-\x80/./file/"); + let normalized = path(b"/sugar-path/invalid-\x80/file"); + assert_ambient(&dirty, &normalized, true, "dirty absolute"); + + let relative = path(b"pkg/invalid-\x80/./file/"); + let expected = + env::current_dir().expect("read current directory").join(path(b"pkg/invalid-\x80/file")); + assert_ambient(&relative, &expected, true, "relative"); + } +} + +#[cfg(target_family = "windows")] +mod windows { + use std::{ + env, + ffi::OsString, + os::windows::ffi::{OsStrExt, OsStringExt}, + path::PathBuf, + }; + + use super::*; + + const INVALID_UNIT: u16 = 0xd800; + + fn invalid_path(before: &str, after: &str) -> PathBuf { + let mut wide: Vec = before.encode_utf16().collect(); + wide.push(INVALID_UNIT); + wide.extend(after.encode_utf16()); + PathBuf::from(OsString::from_wide(&wide)) + } + + fn assert_wide(actual: &Path, expected: &Path, context: &str) { + assert_eq!( + actual.as_os_str().encode_wide().collect::>(), + expected.as_os_str().encode_wide().collect::>(), + "{context}", + ); + } + + fn current_ordinary_disk_drive(path: &Path) -> Option { + use std::path::{Component, Prefix}; + + match path.components().next() { + Some(Component::Prefix(prefix)) => match prefix.kind() { + Prefix::Disk(drive) => Some(drive), + _ => None, + }, + _ => None, + } + } + + fn assert_ambient(input: &Path, expected: &Path, require_owned: bool, name: &str) { + let strict = input.absolutize(); + assert_wide(&strict, expected, &format!("{name} strict")); + if require_owned { + assert!(matches!(strict, Cow::Owned(_)), "{name} strict should own"); + } + + let fallible = input.try_absolutize().expect("fixture should resolve against cwd"); + assert_wide(&fallible, expected, &format!("{name} try")); + if require_owned { + assert!(matches!(fallible, Cow::Owned(_)), "{name} try should own"); + } + } + + #[test] + fn ambient_absolutization_preserves_invalid_windows_units() { + let cwd = env::current_dir().expect("read current directory"); + + let clean = invalid_path(r"C:\sugar-path\invalid-", r"\file"); + assert_ambient(&clean, &clean, false, "clean absolute"); + + let dirty = invalid_path(r"C:\sugar-path\invalid-", r"\.\file\"); + let normalized = invalid_path(r"C:\sugar-path\invalid-", r"\file"); + assert_ambient(&dirty, &normalized, true, "dirty absolute"); + + let relative = invalid_path(r"pkg\invalid-", r"\.\file\"); + let expected = cwd.join(invalid_path(r"pkg\invalid-", r"\file")); + assert_ambient(&relative, &expected, true, "relative"); + + let root_relative = invalid_path(r"\pkg\invalid-", r"\.\file\"); + let mut expected = cwd.clone(); + expected.push(invalid_path(r"\pkg\invalid-", r"\file")); + assert_ambient(&root_relative, &expected, true, "root relative"); + + if let Some(drive) = current_ordinary_disk_drive(&cwd) { + let prefix = format!("{}:pkg\\invalid-", drive as char); + let drive_relative = invalid_path(&prefix, r"\.\file\"); + let expected = cwd.join(invalid_path(r"pkg\invalid-", r"\file")); + assert_ambient(&drive_relative, &expected, false, "drive relative"); + } + } +} diff --git a/tests/absolutize_windows_roots.rs b/tests/absolutize_windows_roots.rs new file mode 100644 index 0000000..3a56cbb --- /dev/null +++ b/tests/absolutize_windows_roots.rs @@ -0,0 +1,45 @@ +#![cfg(target_family = "windows")] + +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; + +use sugar_path::SugarPath; + +fn assert_explicit_case(cwd: &str, input: &str, expected: &str, name: &str) { + assert!(Path::new(cwd).is_absolute(), "{name}: fixture cwd is not absolute"); + + let borrowed = Path::new(input).absolutize_with(Path::new(cwd)); + assert_eq!(borrowed.as_os_str(), Path::new(expected).as_os_str(), "{name} borrowed cwd"); + assert!(matches!(borrowed, Cow::Owned(_)), "{name} borrowed cwd should own"); + + let owned = Path::new(input).absolutize_with(PathBuf::from(cwd)); + assert_eq!(owned.as_os_str(), Path::new(expected).as_os_str(), "{name} owned cwd"); + assert!(matches!(owned, Cow::Owned(_)), "{name} owned cwd should own"); +} + +#[test] +fn windows_root_relative_inputs_use_the_exact_explicit_prefix() { + let cases = [ + ("disk", r"C:\workspace", r"\pkg\.\temp\..\β\file\", r"C:\pkg\β\file"), + ("disk root", r"C:\workspace", r"\", r"C:\"), + ("UNC", r"\\Server\Share\workspace", r"\pkg\..\file\", r"\\Server\Share\file"), + ("UNC root", r"\\Server\Share\workspace", r"\", r"\\Server\Share\"), + ("verbatim disk", r"\\?\c:\workspace", r"\pkg\..\file\", r"\\?\c:\file"), + ("verbatim disk root", r"\\?\c:\workspace", r"\", r"\\?\c:\"), + ( + "verbatim UNC", + r"\\?\UNC\Server\Share\workspace", + r"\pkg\..\file\", + r"\\?\UNC\Server\Share\file", + ), + // A verbatim UNC prefix is already rooted and absolute without an explicit + // RootDir component, so resolution strips the optional trailing separator. + ("verbatim UNC root", r"\\?\UNC\Server\Share\workspace", r"\", r"\\?\UNC\Server\Share"), + ]; + + for (name, cwd, input, expected) in cases { + assert_explicit_case(cwd, input, expected, name); + } +} diff --git a/tests/absolutize_without_cwd.rs b/tests/absolutize_without_cwd.rs index a468f5c..9e9f91d 100644 --- a/tests/absolutize_without_cwd.rs +++ b/tests/absolutize_without_cwd.rs @@ -22,13 +22,17 @@ fn absolute_paths_do_not_require_current_directory() { let clean_output = clean.absolutize(); assert!(matches!(clean_output, Cow::Borrowed(_))); assert_eq!(clean_output.as_os_str(), clean.as_os_str()); - assert!(clean.try_absolutize().is_ok()); + let clean_try = clean.try_absolutize().expect("clean absolute path should succeed"); + assert!(matches!(clean_try, Cow::Borrowed(_))); + assert_eq!(clean_try.as_os_str(), clean.as_os_str()); let dirty = Path::new("/sugar-path/../file.js/"); let dirty_output = dirty.absolutize(); assert!(matches!(dirty_output, Cow::Owned(_))); assert_eq!(dirty_output.as_os_str(), Path::new("/file.js").as_os_str()); - assert!(dirty.try_absolutize().is_ok()); + let dirty_try = dirty.try_absolutize().expect("dirty absolute path should succeed"); + assert!(matches!(dirty_try, Cow::Owned(_))); + assert_eq!(dirty_try.as_os_str(), Path::new("/file.js").as_os_str()); assert!(Path::new("relative.js").try_absolutize().is_err()); assert!(std::panic::catch_unwind(|| Path::new("relative.js").absolutize()).is_err());