diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-09 18:33:14 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-09 18:33:14 +0900 |
| commit | 72cbecaec29cecc723ce29c87d10048114aeef5b (patch) | |
| tree | 99ee7288595afbf1166503d603d7c5a7d08aaae0 /crates/shirabe-php-shim | |
| parent | f1215ade6e1d6e91a36c8b1c407f5a59a1c2ee0e (diff) | |
| download | php-shirabe-72cbecaec29cecc723ce29c87d10048114aeef5b.tar.gz php-shirabe-72cbecaec29cecc723ce29c87d10048114aeef5b.tar.zst php-shirabe-72cbecaec29cecc723ce29c87d10048114aeef5b.zip | |
feat(symfony-filesystem): finish the Filesystem port and trim its API
doRemove() renames a directory to a random hidden name before emptying
it, and undoes that rename when the final rmdir fails, so a concurrent
process cannot recreate the path mid-removal. It also walks one level at
a time through FilesystemIterator instead of flattening the whole tree,
and lets an inner rmdir failure pass, both as upstream does.
copy() keeps the mode fopen($targetFile, 'w') would have left rather
than the origin's, symlink() and mirror() call readlink() and
getLinkTarget() where upstream does, and a directory iterator that
cannot be opened propagates its UnexpectedValueException instead of
being swallowed or flattened into an IOException.
Error message text is out of scope per docs/known-incompatibilities.md,
so the three TODO(phase-c) markers that only tracked wording are gone,
along with linkException()'s Windows-only branch. So are the arguments
no caller varies -- symlink()'s copyOnWindows, mirror()'s iterator and
options, copy()'s overwriteNewerFiles -- which removes the last
TODO(phase-c) in the file.
New shim functions: readlink, filesystem_iterator, stream_is_local,
strrev and SplFileInfo::getLinkTarget. base64_encode takes bytes so
random_bytes() can feed it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-shim')
| -rw-r--r-- | crates/shirabe-php-shim/src/fs.rs | 43 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/stream.rs | 12 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/string.rs | 11 |
3 files changed, 64 insertions, 2 deletions
diff --git a/crates/shirabe-php-shim/src/fs.rs b/crates/shirabe-php-shim/src/fs.rs index bd5e650a..2142b0e7 100644 --- a/crates/shirabe-php-shim/src/fs.rs +++ b/crates/shirabe-php-shim/src/fs.rs @@ -43,6 +43,35 @@ pub struct FilesystemIterator; impl FilesystemIterator { pub const KEY_AS_PATHNAME: i64 = 256; pub const CURRENT_AS_FILEINFO: i64 = 0; + pub const CURRENT_AS_PATHNAME: i64 = 32; + pub const SKIP_DOTS: i64 = 4096; +} + +/// PHP `new \FilesystemIterator($path, $flags)` flattened to the entries it yields: the direct +/// children of `path`, in readdir order, without descending into subdirectories. +pub fn filesystem_iterator( + path: impl AsRef<std::path::Path>, + flags: i64, +) -> Result<Vec<String>, UnexpectedValueException> { + assert!( + flags & FilesystemIterator::CURRENT_AS_PATHNAME != 0, + "filesystem_iterator yields pathnames, so CURRENT_AS_PATHNAME must be set" + ); + assert!( + flags & FilesystemIterator::SKIP_DOTS != 0, + "filesystem_iterator does not model the \".\" and \"..\" entries, so SKIP_DOTS must be set" + ); + let base = path.as_ref(); + let rd = std::fs::read_dir(base).map_err(|_| { + UnexpectedValueException::new(format!( + "FilesystemIterator::__construct({}): Failed to open directory", + base.display() + )) + })?; + Ok(rd + .flatten() + .map(|entry| entry.path().to_string_lossy().into_owned()) + .collect()) } #[derive(Debug, Clone)] @@ -161,6 +190,11 @@ impl RecursiveIteratorFileInfo { self.path.to_string_lossy().into_owned() } + // SplFileInfo::getLinkTarget(): readlink() on the entry. None is PHP's false-on-failure. + pub fn get_link_target(&self) -> Option<String> { + readlink(&self.path) + } + pub fn get_size(&self) -> i64 { std::fs::metadata(&self.path) .map(|m| m.len() as i64) @@ -818,6 +852,15 @@ pub fn is_dir(path: impl AsRef<std::path::Path>) -> bool { path.as_ref().is_dir() } +/// PHP `readlink()`: the target the link points at, without resolving it further. +/// `None` is PHP's `false`-on-failure. +/// TODO(phase-e): byte-string semantics -- PHP returns the raw bytes of the link target. +pub fn readlink(path: impl AsRef<std::path::Path>) -> Option<String> { + std::fs::read_link(path) + .ok() + .map(|target| target.to_string_lossy().into_owned()) +} + pub fn fileatime(_filename: impl AsRef<std::path::Path>) -> Option<i64> { std::fs::metadata(_filename.as_ref()) .ok() diff --git a/crates/shirabe-php-shim/src/stream.rs b/crates/shirabe-php-shim/src/stream.rs index daf4c0af..b699b0ae 100644 --- a/crates/shirabe-php-shim/src/stream.rs +++ b/crates/shirabe-php-shim/src/stream.rs @@ -85,6 +85,18 @@ pub fn stream_isatty(stream: PhpResource) -> bool { stream_isatty_resource(&stream) } +/// PHP `stream_is_local()`: true for plain paths and the `file://` wrapper, false for remote +/// wrappers (`http://`, `ftp://`, ...). +/// TODO(phase-c): PHP asks the wrapper registered for the path's scheme whether it is flagged +/// `STREAM_IS_URL`; this classifies by the scheme itself, so a registered custom wrapper claiming to +/// be local (or vice versa) comes out differently than in PHP. +pub fn stream_is_local(path: &str) -> bool { + match crate::parse_url(path, crate::PHP_URL_SCHEME) { + PhpMixed::String(scheme) => scheme.eq_ignore_ascii_case("file"), + _ => true, + } +} + pub fn stream_get_wrappers() -> Vec<String> { // The full registered set depends on compiled-in extensions and runtime // `stream_wrapper_register` calls, which are not modeled. We return the wrappers always diff --git a/crates/shirabe-php-shim/src/string.rs b/crates/shirabe-php-shim/src/string.rs index 78688eed..a322e36f 100644 --- a/crates/shirabe-php-shim/src/string.rs +++ b/crates/shirabe-php-shim/src/string.rs @@ -179,6 +179,13 @@ pub fn strrpos(_haystack: &str, _needle: &str) -> Option<usize> { _haystack.rfind(_needle) } +// Byte-based, matching PHP: strrev() reverses the bytes, not the characters. +pub fn strrev(s: &str) -> String { + let mut bytes = s.as_bytes().to_vec(); + bytes.reverse(); + String::from_utf8_lossy(&bytes).into_owned() +} + pub fn strtolower(_s: &str) -> String { _s.to_ascii_lowercase() } @@ -485,9 +492,9 @@ pub fn urlencode(s: &str) -> String { out } -pub fn base64_encode(_data: &str) -> String { +pub fn base64_encode(_data: impl AsRef<[u8]>) -> String { const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let bytes = _data.as_bytes(); + let bytes = _data.as_ref(); let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { let b1 = chunk.get(1).copied(); |
