aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-symfony-filesystem
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-09 11:15:55 +0900
committernsfisis <nsfisis@gmail.com>2026-08-09 11:15:55 +0900
commit64e02ef08f0f479937fc194f79eac997327a79dd (patch)
treec32eab75e60f300242b65296937cb3ceffbe02b1 /crates/shirabe-symfony-filesystem
parent446f719f6c34453f027d5ccdbf81b16e63f4c982 (diff)
downloadphp-shirabe-64e02ef08f0f479937fc194f79eac997327a79dd.tar.gz
php-shirabe-64e02ef08f0f479937fc194f79eac997327a79dd.tar.zst
php-shirabe-64e02ef08f0f479937fc194f79eac997327a79dd.zip
refactor(symfony-filesystem): extract symfony/filesystem into the shirabe-symfony-filesystem crate
Move `Symfony\Component\Filesystem` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_filesystem::Filesystem` instead of `shirabe_external_packages::symfony::filesystem::Filesystem`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-symfony-filesystem')
-rw-r--r--crates/shirabe-symfony-filesystem/Cargo.toml12
-rw-r--r--crates/shirabe-symfony-filesystem/src/exception.rs3
-rw-r--r--crates/shirabe-symfony-filesystem/src/exception/io_exception.rs29
-rw-r--r--crates/shirabe-symfony-filesystem/src/filesystem.rs509
-rw-r--r--crates/shirabe-symfony-filesystem/src/lib.rs5
5 files changed, 558 insertions, 0 deletions
diff --git a/crates/shirabe-symfony-filesystem/Cargo.toml b/crates/shirabe-symfony-filesystem/Cargo.toml
new file mode 100644
index 00000000..93de28e9
--- /dev/null
+++ b/crates/shirabe-symfony-filesystem/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "shirabe-symfony-filesystem"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
+shirabe-php-shim.workspace = true
+anyhow.workspace = true
+indexmap.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/shirabe-symfony-filesystem/src/exception.rs b/crates/shirabe-symfony-filesystem/src/exception.rs
new file mode 100644
index 00000000..2c194051
--- /dev/null
+++ b/crates/shirabe-symfony-filesystem/src/exception.rs
@@ -0,0 +1,3 @@
+pub mod io_exception;
+
+pub use io_exception::*;
diff --git a/crates/shirabe-symfony-filesystem/src/exception/io_exception.rs b/crates/shirabe-symfony-filesystem/src/exception/io_exception.rs
new file mode 100644
index 00000000..07a189c3
--- /dev/null
+++ b/crates/shirabe-symfony-filesystem/src/exception/io_exception.rs
@@ -0,0 +1,29 @@
+//! ref: composer/vendor/symfony/filesystem/Exception/IOException.php
+
+use shirabe_php_shim::RuntimeException;
+
+#[derive(Debug)]
+pub struct IOException {
+ inner: RuntimeException,
+ pub path: Option<String>,
+}
+
+impl IOException {
+ pub fn new(
+ message: String,
+ code: i64,
+ previous: Option<std::sync::Arc<anyhow::Error>>,
+ path: Option<String>,
+ ) -> Self {
+ Self {
+ inner: RuntimeException::with_code_and_previous(message, code, previous),
+ path,
+ }
+ }
+}
+
+shirabe_php_shim::impl_php_exception!(
+ IOException,
+ inner,
+ r"Symfony\Component\Filesystem\Exception\IOException"
+);
diff --git a/crates/shirabe-symfony-filesystem/src/filesystem.rs b/crates/shirabe-symfony-filesystem/src/filesystem.rs
new file mode 100644
index 00000000..45c836a0
--- /dev/null
+++ b/crates/shirabe-symfony-filesystem/src/filesystem.rs
@@ -0,0 +1,509 @@
+//! ref: composer/vendor/symfony/filesystem/Filesystem.php
+
+// TODO(phase-c): PHP's box()/self::$lastError mechanism captures the warning text emitted by the
+// failed native call. The low-level error appended to the IOException messages below is instead the
+// Rust io::Error text, which words the same errno differently (e.g. "Permission denied (os error
+// 13)").
+
+use crate::exception::io_exception::IOException;
+use shirabe_php_shim::PhpMixed;
+
+#[derive(Debug, Clone)]
+pub struct Filesystem;
+
+impl Default for Filesystem {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Filesystem {
+ pub fn new() -> Self {
+ Filesystem
+ }
+
+ // Symfony's toIterable(): a single string yields one element, an array/iterable yields each
+ // element as a string.
+ fn to_iterable(files: &PhpMixed) -> Vec<String> {
+ match files {
+ PhpMixed::String(s) => vec![s.clone()],
+ PhpMixed::List(items) => items
+ .iter()
+ .map(|item| item.as_string().unwrap_or("").to_string())
+ .collect(),
+ PhpMixed::Array(entries) => entries
+ .values()
+ .map(|item| item.as_string().unwrap_or("").to_string())
+ .collect(),
+ _ => vec![files.as_string().unwrap_or("").to_string()],
+ }
+ }
+
+ fn copy(
+ &self,
+ origin_file: &str,
+ target_file: &str,
+ override_file: bool,
+ ) -> anyhow::Result<()> {
+ // PHP: stream_is_local($originFile) || 0 === stripos($originFile, 'file://')
+ let origin_is_local = Self::stream_is_local(origin_file)
+ || shirabe_php_shim::stripos(origin_file, "file://") == Some(0);
+
+ if origin_is_local && !shirabe_php_shim::is_file(origin_file) {
+ return Err(IOException::new(
+ format!(
+ "Failed to copy \"{}\" because file does not exist.",
+ origin_file
+ ),
+ 0,
+ None,
+ Some(origin_file.to_string()),
+ )
+ .into());
+ }
+
+ self.mkdir(
+ PhpMixed::String(shirabe_php_shim::dirname(target_file)),
+ 0o777,
+ )?;
+
+ let mut do_copy = true;
+ // PHP: !$overwriteNewerFiles && !parse_url($originFile, PHP_URL_HOST) && is_file($targetFile)
+ let origin_host = shirabe_php_shim::parse_url(origin_file, shirabe_php_shim::PHP_URL_HOST);
+ if !override_file
+ && matches!(origin_host, PhpMixed::Null | PhpMixed::Bool(false))
+ && shirabe_php_shim::is_file(target_file)
+ {
+ do_copy = shirabe_php_shim::filemtime(origin_file).unwrap_or(0)
+ > shirabe_php_shim::filemtime(target_file).unwrap_or(0);
+ }
+
+ if do_copy {
+ let bytes_copied = match shirabe_php_shim::copy(origin_file, target_file) {
+ true => shirabe_php_shim::filesize(target_file).unwrap_or(0),
+ false => {
+ // TODO(phase-c): PHP distinguishes fopen($originFile) failure ("source file
+ // could not be opened for reading") from fopen($targetFile) failure ("target
+ // file could not be opened for writing"); the shim's copy() collapses both
+ // (and the actual copy failure) into this single generic message.
+ return Err(IOException::new(
+ format!("Failed to copy \"{}\" to \"{}\".", origin_file, target_file),
+ 0,
+ None,
+ Some(origin_file.to_string()),
+ )
+ .into());
+ }
+ };
+
+ if !shirabe_php_shim::is_file(target_file) {
+ return Err(IOException::new(
+ format!("Failed to copy \"{}\" to \"{}\".", origin_file, target_file),
+ 0,
+ None,
+ Some(origin_file.to_string()),
+ )
+ .into());
+ }
+
+ if origin_is_local {
+ // Like `cp`, preserve executable permission bits.
+ shirabe_php_shim::chmod(
+ target_file,
+ (shirabe_php_shim::fileperms(target_file)
+ | (shirabe_php_shim::fileperms(origin_file) & 0o111))
+ as u32,
+ );
+
+ // Like `cp`, preserve the file modification time.
+ shirabe_php_shim::touch2(
+ target_file,
+ shirabe_php_shim::filemtime(origin_file).unwrap_or(0),
+ );
+
+ let bytes_origin = shirabe_php_shim::filesize(origin_file).unwrap_or(0);
+ if bytes_copied != bytes_origin {
+ return Err(IOException::new(
+ format!(
+ "Failed to copy the whole content of \"{}\" to \"{}\" ({} of {} bytes copied).",
+ origin_file, target_file, bytes_copied, bytes_origin
+ ),
+ 0,
+ None,
+ Some(origin_file.to_string()),
+ )
+ .into());
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ fn mkdir(&self, dirs: PhpMixed, mode: u32) -> anyhow::Result<()> {
+ for dir in Self::to_iterable(&dirs) {
+ if shirabe_php_shim::is_dir(&dir) {
+ continue;
+ }
+
+ if let Err(last_error) = shirabe_php_shim::mkdir(&dir, mode, true)
+ && !shirabe_php_shim::is_dir(&dir)
+ {
+ return Err(IOException::new(
+ format!("Failed to create \"{}\": {}", dir, last_error),
+ 0,
+ None,
+ Some(dir),
+ )
+ .into());
+ }
+ }
+ Ok(())
+ }
+
+ fn exists(&self, files: PhpMixed) -> anyhow::Result<bool> {
+ let max_path_length = shirabe_php_shim::PHP_MAXPATHLEN - 2;
+
+ for file in Self::to_iterable(&files) {
+ if file.len() as i64 > max_path_length {
+ return Err(IOException::new(
+ format!(
+ "Could not check if file exist because path length exceeds {} characters.",
+ max_path_length
+ ),
+ 0,
+ None,
+ Some(file),
+ )
+ .into());
+ }
+
+ if !shirabe_php_shim::file_exists(&file) {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+ }
+
+ fn remove(&self, files: PhpMixed) -> anyhow::Result<()> {
+ let files = Self::to_iterable(&files);
+ Self::do_remove(files, false)
+ }
+
+ // TODO(phase-c): `is_recursive` is unused. In PHP, doRemove() uses it as a top-level-call guard:
+ // on the first (non-recursive) call for a directory, it renames the directory to a random
+ // hidden name before recursing into it (and renames it back if rmdir subsequently fails), to
+ // avoid a race where another process recreates the path mid-removal. That rename/rollback
+ // trick is entirely unported here; this version always operates on the original path.
+ fn do_remove(files: Vec<String>, is_recursive: bool) -> anyhow::Result<()> {
+ // PHP reverses the list so that directory contents are removed before the directory itself.
+ let mut files = files;
+ files.reverse();
+ for file in files {
+ if shirabe_php_shim::is_link(&file) {
+ // See https://bugs.php.net/52176
+ let unlinked = shirabe_php_shim::unlink(&file);
+ let mut last_error = unlinked.as_ref().err().map(ToString::to_string);
+ let mut removed = unlinked.is_ok() || !cfg!(windows);
+ if !removed {
+ match shirabe_php_shim::rmdir(&file) {
+ Ok(()) => {
+ last_error = None;
+ removed = true;
+ }
+ Err(e) => last_error = Some(e.to_string()),
+ }
+ }
+ if !removed && shirabe_php_shim::file_exists(&file) {
+ return Err(IOException::new(
+ format!(
+ "Failed to remove symlink \"{}\": {}",
+ file,
+ last_error.unwrap_or_default()
+ ),
+ 0,
+ None,
+ None,
+ )
+ .into());
+ }
+ } else if shirabe_php_shim::is_dir(&file) {
+ let entries = match shirabe_php_shim::recursive_directory_iterator(
+ &file,
+ shirabe_php_shim::FilesystemIterator::KEY_AS_PATHNAME
+ | shirabe_php_shim::SKIP_DOTS,
+ ) {
+ Ok(dir) => shirabe_php_shim::recursive_iterator_iterator(
+ dir,
+ shirabe_php_shim::RecursiveIteratorIterator::CHILD_FIRST,
+ ),
+ Err(_) => {
+ return Err(IOException::new(
+ format!("Failed to remove directory \"{}\": ", file),
+ 0,
+ None,
+ None,
+ )
+ .into());
+ }
+ };
+ let child_paths: Vec<String> =
+ (&entries).into_iter().map(|e| e.get_pathname()).collect();
+ Self::do_remove(child_paths, true)?;
+
+ if let Err(last_error) = shirabe_php_shim::rmdir(&file)
+ && shirabe_php_shim::file_exists(&file)
+ {
+ return Err(IOException::new(
+ format!("Failed to remove directory \"{}\": {}", file, last_error),
+ 0,
+ None,
+ None,
+ )
+ .into());
+ }
+ } else if let Err(last_error) = shirabe_php_shim::unlink(&file) {
+ let last_error = last_error.to_string();
+ if last_error.contains("Permission denied") || shirabe_php_shim::file_exists(&file)
+ {
+ return Err(IOException::new(
+ format!("Failed to remove file \"{}\": {}", file, last_error),
+ 0,
+ None,
+ None,
+ )
+ .into());
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub fn symlink(
+ &self,
+ origin_dir: &str,
+ target_dir: &str,
+ copy_on_windows: bool,
+ ) -> anyhow::Result<()> {
+ let mut origin_dir = origin_dir.to_string();
+ let mut target_dir = target_dir.to_string();
+
+ if cfg!(windows) {
+ origin_dir = shirabe_php_shim::strtr(&origin_dir, "/", "\\");
+ target_dir = shirabe_php_shim::strtr(&target_dir, "/", "\\");
+
+ if copy_on_windows {
+ return self.mirror(&origin_dir, &target_dir, None, &indexmap::IndexMap::new());
+ }
+ }
+
+ self.mkdir(
+ PhpMixed::String(shirabe_php_shim::dirname(&target_dir)),
+ 0o777,
+ )?;
+
+ if shirabe_php_shim::is_link(&target_dir) {
+ if self.read_link(&target_dir) == origin_dir {
+ return Ok(());
+ }
+ self.remove(PhpMixed::String(target_dir.clone()))?;
+ }
+
+ if let Err(last_error) = shirabe_php_shim::symlink(&origin_dir, &target_dir) {
+ return Self::link_exception(
+ &origin_dir,
+ &target_dir,
+ "symbolic",
+ &last_error.to_string(),
+ );
+ }
+ Ok(())
+ }
+
+ fn link_exception(
+ origin: &str,
+ target: &str,
+ link_type: &str,
+ last_error: &str,
+ ) -> anyhow::Result<()> {
+ // TODO(phase-c): the Windows io::Error text renders the error number as "(os error 1314)",
+ // so this substring never matches and the generic message below is thrown instead.
+ if !last_error.is_empty() && cfg!(windows) && last_error.contains("error code(1314)") {
+ return Err(IOException::new(
+ format!(
+ "Unable to create \"{}\" link due to error code 1314: 'A required privilege is not held by the client'. Do you have the required Administrator-rights?",
+ link_type
+ ),
+ 0,
+ None,
+ Some(target.to_string()),
+ )
+ .into());
+ }
+ Err(IOException::new(
+ format!(
+ "Failed to create \"{}\" link from \"{}\" to \"{}\": {}",
+ link_type, origin, target, last_error
+ ),
+ 0,
+ None,
+ Some(target.to_string()),
+ )
+ .into())
+ }
+
+ // TODO(phase-c): this only ports Symfony's readlink($path, $canonicalize = false) overload;
+ // the $canonicalize = true branch (realpath()-based resolution, returning null if the path
+ // does not exist at all) is entirely unported. Following the numbered-suffix convention
+ // for PHP default arguments, it should become a `read_link2` overload if ever needed.
+ fn read_link(&self, path: &str) -> String {
+ // Symfony's readlink() with $canonicalize = false: returns null if the path is not a link.
+ // TODO(phase-c): the Rust signature is non-Option, so the non-link case yields the path's
+ // readlink result (empty string on failure) instead of PHP's null, to keep the symlink()
+ // caller working. Every current caller checks is_link() first, so this never triggers on
+ // the live code paths, but the collapsed Option<String> -> String signature is a real
+ // divergence from upstream.
+ // TODO(phase-c): PHP also has `if ('\\' === DIRECTORY_SEPARATOR && PHP_VERSION_ID < 70400)
+ // return realpath($path);` ahead of the plain readlink() call below, working around a
+ // pre-7.4 Windows bug. Not ported here; on old Windows PHP this would resolve differently.
+ std::fs::read_link(path)
+ .ok()
+ .map(|p| p.to_string_lossy().into_owned())
+ .unwrap_or_default()
+ }
+
+ // PHP stream_is_local(): true for plain paths and the file:// wrapper, false for remote
+ // wrappers (http://, ftp://, ...).
+ // TODO(phase-c): this is PHP's built-in stream_is_local(), which queries the registered stream
+ // wrapper for STREAM_IS_URL rather than just parsing the scheme. It is approximated here via
+ // parse_url()'s scheme instead of being added to shirabe-php-shim, so a registered custom
+ // stream wrapper claiming to be local (or vice versa) would be classified differently than PHP.
+ fn stream_is_local(path: &str) -> bool {
+ let scheme = shirabe_php_shim::parse_url(path, shirabe_php_shim::PHP_URL_SCHEME);
+ match scheme {
+ PhpMixed::Null | PhpMixed::Bool(false) => true,
+ PhpMixed::String(s) => s.eq_ignore_ascii_case("file"),
+ _ => true,
+ }
+ }
+
+ pub fn mirror(
+ &self,
+ origin_dir: &str,
+ target_dir: &str,
+ iterator: Option<PhpMixed>,
+ options: &indexmap::IndexMap<String, PhpMixed>,
+ ) -> anyhow::Result<()> {
+ if iterator.is_some() {
+ // TODO(phase-c): Symfony's mirror() accepts a \Traversable filter iterator. The
+ // external-package Filesystem does not model that iterator type, and every caller passes
+ // None, so the filtered case is left unimplemented.
+ todo!()
+ }
+
+ let target_dir = shirabe_php_shim::rtrim(target_dir, Some("/\\"));
+ let origin_dir = shirabe_php_shim::rtrim(origin_dir, Some("/\\"));
+ let origin_dir_len = origin_dir.len();
+
+ if !self.exists(PhpMixed::String(origin_dir.clone()))? {
+ return Err(IOException::new(
+ format!(
+ "The origin directory specified \"{}\" was not found.",
+ origin_dir
+ ),
+ 0,
+ None,
+ Some(origin_dir),
+ )
+ .into());
+ }
+
+ let delete = options
+ .get("delete")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ // Iterate in destination folder to remove obsolete entries.
+ if self.exists(PhpMixed::String(target_dir.clone()))? && delete {
+ let target_dir_len = target_dir.len();
+ if let Ok(dir) = shirabe_php_shim::recursive_directory_iterator(
+ &target_dir,
+ shirabe_php_shim::SKIP_DOTS,
+ ) {
+ let delete_iterator = shirabe_php_shim::recursive_iterator_iterator(
+ dir,
+ shirabe_php_shim::RecursiveIteratorIterator::CHILD_FIRST,
+ );
+ for file in &delete_iterator {
+ let pathname = file.get_pathname();
+ let origin = format!("{}{}", origin_dir, &pathname[target_dir_len..]);
+ if !self.exists(PhpMixed::String(origin))? {
+ self.remove(PhpMixed::String(pathname))?;
+ }
+ }
+ }
+ }
+
+ let copy_on_windows = options
+ .get("copy_on_windows")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ let flags = if copy_on_windows {
+ shirabe_php_shim::SKIP_DOTS
+ | shirabe_php_shim::RecursiveDirectoryIterator::FOLLOW_SYMLINKS
+ } else {
+ shirabe_php_shim::SKIP_DOTS
+ };
+ let dir = shirabe_php_shim::recursive_directory_iterator(&origin_dir, flags)
+ .map_err(|e| anyhow::anyhow!("{}", e.get_message()))?;
+ let iterator = shirabe_php_shim::recursive_iterator_iterator(
+ dir,
+ shirabe_php_shim::RecursiveIteratorIterator::SELF_FIRST,
+ );
+
+ self.mkdir(PhpMixed::String(target_dir.clone()), 0o777)?;
+
+ let override_file = options
+ .get("override")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ let mut files_created_while_mirroring: indexmap::IndexMap<String, bool> =
+ indexmap::IndexMap::new();
+
+ for file in &iterator {
+ let pathname = file.get_pathname();
+ // SplFileInfo::getRealPath(), which returns false for a path that cannot be resolved.
+ let real_path = shirabe_php_shim::realpath(&pathname);
+ if pathname == target_dir
+ || real_path.as_deref() == Some(target_dir.as_str())
+ || real_path
+ .as_ref()
+ .is_some_and(|p| files_created_while_mirroring.contains_key(p))
+ {
+ continue;
+ }
+
+ let target = format!("{}{}", target_dir, &pathname[origin_dir_len..]);
+ files_created_while_mirroring.insert(target.clone(), true);
+
+ if !copy_on_windows && file.is_link() {
+ self.symlink(&self.read_link(&pathname), &target, false)?;
+ } else if file.is_dir() {
+ self.mkdir(PhpMixed::String(target), 0o777)?;
+ } else if file.is_file() {
+ self.copy(&pathname, &target, override_file)?;
+ } else {
+ return Err(IOException::new(
+ format!("Unable to guess \"{}\" file type.", pathname),
+ 0,
+ None,
+ Some(pathname),
+ )
+ .into());
+ }
+ }
+ Ok(())
+ }
+}
diff --git a/crates/shirabe-symfony-filesystem/src/lib.rs b/crates/shirabe-symfony-filesystem/src/lib.rs
new file mode 100644
index 00000000..24be2ed5
--- /dev/null
+++ b/crates/shirabe-symfony-filesystem/src/lib.rs
@@ -0,0 +1,5 @@
+pub mod exception;
+pub mod filesystem;
+
+pub use exception::*;
+pub use filesystem::*;