diff --git a/.agents/docs/gotchas.md b/.agents/docs/gotchas.md index b5d3434..c2e91a1 100644 --- a/.agents/docs/gotchas.md +++ b/.agents/docs/gotchas.md @@ -30,7 +30,7 @@ An explicit cwd must be absolute only when the operation needs it. An absolute r ## The cached current directory is process-lifetime state -With `cached_current_dir`, the first operation that actually needs the ordinary process cwd initializes a `OnceLock`, and later operations reuse that path. Absolute inputs and cwd-independent relative pairs do not initialize the cache. A later `std::env::set_current_dir` after initialization is invisible to this provider. Enable the feature only when the process treats cwd as stable, or use `absolutize_with` and `relative_with` for changing or externally managed cwd state. +With `cached_current_dir`, the first operation that actually needs the ordinary process cwd initializes a `OnceLock`, and later operations reuse that path. Absolute inputs and cwd-independent relative pairs do not initialize the cache. A later `std::env::set_current_dir`, directory removal, or permission change after initialization is invisible to this provider; it continues lexical resolution from the successful snapshot without revalidating that path. Enable the feature only when the process treats cwd as stable, or use `absolutize_with` and `relative_with` for changing or externally managed cwd state. Windows drive-relative ambient resolution is a separate case: `C:foo` needs drive C's remembered cwd, not merely the process's single cached cwd. It goes through `std::path::absolute`, so the `cached_current_dir` feature must not substitute an unrelated drive context. @@ -64,6 +64,7 @@ There is no public fused relative-to-string method. The strict owned-string pipe - [Exact normalization identity tests](../../tests/path_identity.rs) - [Explicit-cwd absolutization tests](../../tests/absolutize_with.rs) - [Ambient cwd avoidance tests](../../tests/absolutize_without_cwd.rs) +- [Unavailable cwd after a successful lookup](../../tests/cwd_unavailable_after_cache.rs) - [Slash-conversion tests](../../tests/to_slash.rs) - [Consuming ownership tests](../../tests/owned_api.rs) - [Relative-to-slash composition tests](../../tests/relative_to_slash.rs) diff --git a/.agents/docs/testing-strategy.md b/.agents/docs/testing-strategy.md index 0f9f2c7..4054257 100644 --- a/.agents/docs/testing-strategy.md +++ b/.agents/docs/testing-strategy.md @@ -41,7 +41,8 @@ The matrix is interaction-aware. A platform root kind must be crossed with encod - On macOS and Linux, all 224,676 pairs from the bounded short absolute spelling set plus the multibyte set are checked through the production `relative_str` dispatch and the suffix-validation helper against the slow component oracle. - Absolute paths with native-invalid normal components prove that equal raw encoding cancels and distinct encoding with the same lossy rendering does not, through `relative`, `try_relative`, and `relative_with` on Unix and Windows. Unix explicit-cwd rows also prove that relative resolution preserves native-invalid cwd components for borrowed and owned context arguments. - Windows drive-relative rows apply the same exact comparison before shared-context cancellation and preserve invalid-wide components while resolving against explicit borrowed and owned cwd arguments. -- Unavailable Unix cwd coverage pins exact successful output and `Cow` variants for cwd-independent fallible calls, preserves the ambient error and panic checks for dependent `Path`, `str`, and `String` calls, and proves that a valid explicit cwd still succeeds. +- Unavailable Unix cwd coverage pins exact successful output and `Cow` variants for cwd-independent fallible calls, compares dependent fallible calls with the direct `current_dir` error, preserves strict panic checks for `Path`, `str`, and `String` calls, and proves that a valid explicit cwd still succeeds. +- A Unix child process successfully reads cwd before removing that directory, then requires default mode to expose the subsequent ambient error while `cached_current_dir` continues producing exact owned results from the successful process-lifetime snapshot. - Known-UTF-8 `str` and `String` receivers prove exact clean-trailing and dirty normalization contracts, receiver-only borrowing, explicit and ambient relative context forwarding, and cached-cwd behavior on Unix and Windows. Unix deleted-cwd rows also prove cwd-independent success and strict cwd-dependent panics for string receivers. - A Linux child process starts in a real cwd containing an invalid native byte and requires `absolutize`, `try_absolutize`, `relative`, and `try_relative` to preserve that byte exactly under both cwd policies. Linux supplies this executable filesystem case because macOS rejects the invalid-byte directory name; explicit-cwd tests continue to cover native-invalid Unix composition independently of the host filesystem. @@ -71,6 +72,7 @@ Any change to a public contract, platform branch, native-encoding comparison, cl - [Independent bounded public normalization model](../../tests/public_normalize_model.rs) - [Relative ownership and lifetime contracts](../../tests/relative_borrowing.rs) - [Unavailable-cwd relative behavior](../../tests/relative_without_cwd.rs) +- [Unavailable cwd after a successful lookup](../../tests/cwd_unavailable_after_cache.rs) - [Known-UTF-8 string receiver ownership](../../tests/string_receiver_contracts.rs) - [Native-invalid ambient cwd preservation](../../tests/non_utf8_ambient_cwd.rs) - [Native-invalid exact comparison](../../tests/invalid_encoding.rs) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1b9db81..5b09716 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -71,6 +71,7 @@ jobs: current_directory_is_cached_only_when_requested pure_relative_calls_initialize_and_observe_cwd_policy failed_cwd_lookup_does_not_poison_later_relative_calls + cwd_unavailable_after_successful_lookup_observes_cache_policy non_utf8_ambient_cwd_is_preserved requested_cached_current_dir_configuration_is_active - os: windows-latest @@ -108,6 +109,7 @@ jobs: current_directory_is_cached_only_when_requested pure_relative_calls_initialize_and_observe_cwd_policy failed_cwd_lookup_does_not_poison_later_relative_calls + cwd_unavailable_after_successful_lookup_observes_cache_policy requested_cached_current_dir_configuration_is_active runs-on: ${{ matrix.os }} steps: diff --git a/tests/absolutize_without_cwd.rs b/tests/absolutize_without_cwd.rs index 163ba0f..9b901aa 100644 --- a/tests/absolutize_without_cwd.rs +++ b/tests/absolutize_without_cwd.rs @@ -2,7 +2,7 @@ use std::{ borrow::Cow, - env, fs, + env, fs, io, path::Path, process::Command, time::{SystemTime, UNIX_EPOCH}, @@ -12,11 +12,16 @@ use sugar_path::SugarPath; const CHILD_ENV: &str = "SUGAR_PATH_TEST_WITHOUT_CWD"; +fn assert_same_error(actual: &io::Error, expected: &io::Error, context: &str) { + assert_eq!(actual.kind(), expected.kind(), "{context} error kind"); + assert_eq!(actual.raw_os_error(), expected.raw_os_error(), "{context} raw OS error"); +} + #[test] fn absolute_paths_do_not_require_current_directory() { if let Some(doomed) = env::var_os(CHILD_ENV) { fs::remove_dir(&doomed).expect("remove the child's current directory"); - assert!(env::current_dir().is_err()); + let cwd_error = env::current_dir().expect_err("the process cwd is unavailable"); let clean = Path::new("/sugar-path/file.js"); let clean_output = clean.absolutize(); @@ -34,9 +39,21 @@ fn absolute_paths_do_not_require_current_directory() { 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!("relative.js".try_absolutize().is_err()); - assert!(String::from("relative.js").try_absolutize().is_err()); + assert_same_error( + &Path::new("relative.js").try_absolutize().expect_err("relative Path should need cwd"), + &cwd_error, + "relative Path try_absolutize", + ); + assert_same_error( + &"relative.js".try_absolutize().expect_err("relative str should need cwd"), + &cwd_error, + "relative str try_absolutize", + ); + assert_same_error( + &String::from("relative.js").try_absolutize().expect_err("relative String should need cwd"), + &cwd_error, + "relative String try_absolutize", + ); assert!(std::panic::catch_unwind(|| Path::new("relative.js").absolutize()).is_err()); assert!( std::panic::catch_unwind(|| { @@ -46,6 +63,10 @@ fn absolute_paths_do_not_require_current_directory() { .is_err(), ); + let explicit = Path::new("relative.js").absolutize_with(Path::new("/explicit-cwd")); + assert_eq!(explicit.as_ref(), Path::new("/explicit-cwd/relative.js")); + assert!(matches!(explicit, Cow::Owned(_))); + let clean_string = String::from("/sugar-path/string.js"); let clean_string_output = clean_string.try_absolutize().expect("clean absolute String should not read cwd"); diff --git a/tests/cwd_unavailable_after_cache.rs b/tests/cwd_unavailable_after_cache.rs new file mode 100644 index 0000000..c4c3277 --- /dev/null +++ b/tests/cwd_unavailable_after_cache.rs @@ -0,0 +1,118 @@ +#![cfg(unix)] + +use std::{ + borrow::Cow, + env, fs, io, + panic::{AssertUnwindSafe, catch_unwind}, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use sugar_path::SugarPath; + +const CHILD_ENV: &str = "SUGAR_PATH_CWD_UNAVAILABLE_AFTER_CACHE"; + +fn assert_same_error(actual: &io::Error, expected: &io::Error, context: &str) { + assert_eq!(actual.kind(), expected.kind(), "{context} error kind"); + assert_eq!(actual.raw_os_error(), expected.raw_os_error(), "{context} raw OS error"); +} + +fn assert_owned(output: Cow<'_, Path>, expected: &Path, context: &str) { + assert_eq!(output.as_ref(), expected, "{context} value"); + assert!(matches!(output, Cow::Owned(_)), "{context} should own"); +} + +#[test] +fn cwd_unavailable_after_successful_lookup_observes_cache_policy() { + if let Some(root) = env::var_os(CHILD_ENV) { + let root = PathBuf::from(root); + let doomed = root.join("doomed"); + assert_eq!(env::current_dir().expect("read the child's initial cwd"), doomed); + let anchor = root.join("anchor"); + + assert_owned( + Path::new("initial.js").try_absolutize().expect("initialize cwd state"), + &doomed.join("initial.js"), + "initial cwd lookup", + ); + fs::remove_dir(&doomed).expect("remove the child's current directory"); + let cwd_error = env::current_dir().expect_err("the process cwd is unavailable"); + + let absolute = Path::new("later.js").try_absolutize(); + let relative = Path::new("later.js").try_relative(&anchor); + if cfg!(feature = "cached_current_dir") { + assert_owned( + absolute.expect("cached absolutize should succeed"), + &doomed.join("later.js"), + "cached try_absolutize", + ); + assert_owned( + relative.expect("cached relative should succeed"), + Path::new("../doomed/later.js"), + "cached try_relative", + ); + assert_owned( + Path::new("later.js").absolutize(), + &doomed.join("later.js"), + "cached absolutize", + ); + assert_owned( + Path::new("later.js").relative(&anchor), + Path::new("../doomed/later.js"), + "cached relative", + ); + } else { + assert_same_error( + &absolute.expect_err("default absolutize should fail"), + &cwd_error, + "default try_absolutize", + ); + assert_same_error( + &relative.expect_err("default relative should fail"), + &cwd_error, + "default try_relative", + ); + assert!( + catch_unwind(|| Path::new("later.js").absolutize()).is_err(), + "default absolutize should panic", + ); + assert!( + catch_unwind(AssertUnwindSafe(|| Path::new("later.js").relative(&anchor))).is_err(), + "default relative should panic", + ); + } + return; + } + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after the Unix epoch") + .as_nanos(); + let root = + env::temp_dir().join(format!("sugar-path-cwd-unavailable-{}-{unique}", std::process::id())); + fs::create_dir_all(root.join("doomed")).expect("create the child cwd"); + let root = fs::canonicalize(root).expect("resolve the temporary root spelling"); + let doomed = root.join("doomed"); + + let output = Command::new(env::current_exe().expect("find the integration-test executable")) + .args([ + "--exact", + "cwd_unavailable_after_successful_lookup_observes_cache_policy", + "--nocapture", + ]) + .env(CHILD_ENV, &root) + .current_dir(&doomed) + .output() + .expect("run the integration test in a child process"); + + if root.exists() { + fs::remove_dir_all(&root).expect("clean up the temporary root"); + } + assert!( + output.status.success(), + "child test failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/tests/relative_without_cwd.rs b/tests/relative_without_cwd.rs index ea675bc..8185447 100644 --- a/tests/relative_without_cwd.rs +++ b/tests/relative_without_cwd.rs @@ -2,7 +2,7 @@ use std::{ borrow::Cow, - env, fs, + env, fs, io, panic::{AssertUnwindSafe, catch_unwind}, path::Path, process::Command, @@ -13,11 +13,16 @@ use sugar_path::SugarPath; const CHILD_ENV: &str = "SUGAR_PATH_RELATIVE_WITHOUT_CWD"; +fn assert_same_error(actual: &io::Error, expected: &io::Error, context: &str) { + assert_eq!(actual.kind(), expected.kind(), "{context} error kind"); + assert_eq!(actual.raw_os_error(), expected.raw_os_error(), "{context} raw OS error"); +} + #[test] fn cwd_independent_relative_inputs_do_not_read_the_current_directory() { if let Some(doomed) = env::var_os(CHILD_ENV) { fs::remove_dir(&doomed).expect("remove the child's current directory"); - assert!(env::current_dir().is_err()); + let cwd_error = env::current_dir().expect_err("the process cwd is unavailable"); let dirty = Path::new("./dist/assets/./temp/../index.js").relative(Path::new("dist/./chunks/../chunks")); @@ -34,11 +39,27 @@ fn cwd_independent_relative_inputs_do_not_read_the_current_directory() { assert_eq!(fallible.as_os_str(), Path::new("../assets/index.js").as_os_str()); assert!(matches!(fallible, Cow::Owned(_))); - assert!( - Path::new("../../dist/assets/index.js").try_relative(Path::new("../dist/chunks")).is_err(), + assert_same_error( + &Path::new("../../dist/assets/index.js") + .try_relative(Path::new("../dist/chunks")) + .expect_err("unequal leading parents should need cwd"), + &cwd_error, + "relative Path try_relative", + ); + assert_same_error( + &"../../dist/assets/index.js" + .try_relative("../dist/chunks") + .expect_err("unequal leading parents should need cwd for str"), + &cwd_error, + "relative str try_relative", + ); + assert_same_error( + &String::from("../../dist/assets/index.js") + .try_relative("../dist/chunks") + .expect_err("unequal leading parents should need cwd for String"), + &cwd_error, + "relative String try_relative", ); - assert!("../../dist/assets/index.js".try_relative("../dist/chunks").is_err()); - assert!(String::from("../../dist/assets/index.js").try_relative("../dist/chunks").is_err(),); let absolute_string = String::from("/workspace/src"); let absolute_relative =