aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-shim
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-25 14:39:00 +0900
committernsfisis <nsfisis@gmail.com>2026-06-25 23:47:47 +0900
commitd0336078c5b63b174e7313d54d973a1832228928 (patch)
treeb0ed23c382e7ffd854fd3afb266a6932002e3335 /crates/shirabe-php-shim
parenteebba7ebad103a2f7afe885a25ba2e96efddbd89 (diff)
downloadphp-shirabe-d0336078c5b63b174e7313d54d973a1832228928.tar.gz
php-shirabe-d0336078c5b63b174e7313d54d973a1832228928.tar.zst
php-shirabe-d0336078c5b63b174e7313d54d973a1832228928.zip
feat(external-packages,shim): implement impl todos across components
Port seld/jsonlint JsonParser (+hand-written Lexer), unblocking 10 json_file parse-error tests verified byte-for-byte against PHP. Implement Symfony Finder SplFileInfo, executable finders, String classes (byte/code-point/unicode), ZipArchive shim (via the zip crate), SPDX license validation, and shim date/stream functions. Genuinely-blocked sites (reflection, PHP runtime constants, non-UTF-8 transcoding, recursive PCRE) stay todo!() with reasons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-shim')
-rw-r--r--crates/shirabe-php-shim/src/datetime.rs21
-rw-r--r--crates/shirabe-php-shim/src/output.rs3
-rw-r--r--crates/shirabe-php-shim/src/stream.rs48
-rw-r--r--crates/shirabe-php-shim/src/zip.rs199
4 files changed, 238 insertions, 33 deletions
diff --git a/crates/shirabe-php-shim/src/datetime.rs b/crates/shirabe-php-shim/src/datetime.rs
index 9810966..bddf5da 100644
--- a/crates/shirabe-php-shim/src/datetime.rs
+++ b/crates/shirabe-php-shim/src/datetime.rs
@@ -1,6 +1,10 @@
static DEFAULT_TIMEZONE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
pub fn date_create<Tz: chrono::TimeZone>(s: &str) -> chrono::ParseResult<chrono::DateTime<Tz>> {
+ // TODO(phase-d): PHP `date_create` accepts the full strtotime() grammar (RFC2822, ISO8601,
+ // VCS-specific and relative formats), which requires a dedicated date parser not available
+ // here. The generic `Tz` also has no constructor from a parsed `FixedOffset`/`Utc` value.
+ let _ = s;
todo!()
}
@@ -22,6 +26,8 @@ pub fn date_format_to_strftime(format: &str) -> &'static str {
}
pub fn strtotime(_time: &str) -> Option<i64> {
+ // TODO(phase-d): requires the full strtotime() grammar (absolute, relative and compound
+ // expressions); a partial parser would silently mis-handle unsupported inputs.
todo!()
}
@@ -53,6 +59,17 @@ pub fn date_default_timezone_set(tz: &str) -> bool {
true
}
-pub fn date(_format: &str, _timestamp: Option<i64>) -> String {
- todo!()
+pub fn date(format: &str, timestamp: Option<i64>) -> String {
+ let timestamp = timestamp.unwrap_or_else(time);
+ // PHP `date()` renders in the default timezone. Without a timezone database only "UTC" can be
+ // resolved; any named zone is rejected loudly rather than silently rendered in the wrong zone.
+ let tz = date_default_timezone_get();
+ if tz != "UTC" {
+ panic!(
+ "date() with non-UTC default timezone {tz:?} is not supported (no timezone database)"
+ );
+ }
+ let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
+ .expect("date() timestamp out of range");
+ dt.format(date_format_to_strftime(format)).to_string()
}
diff --git a/crates/shirabe-php-shim/src/output.rs b/crates/shirabe-php-shim/src/output.rs
index 17f7ab9..698f3bf 100644
--- a/crates/shirabe-php-shim/src/output.rs
+++ b/crates/shirabe-php-shim/src/output.rs
@@ -1,3 +1,6 @@
+// PHP output buffering captures everything the interpreter would echo to stdout. The shim has no
+// general echo-to-buffer routing, and its only producer in Composer (`phpinfo`) depends on PHP
+// runtime configuration that is itself unmodeled, so a buffer here would silently capture nothing.
pub fn ob_start() -> bool {
todo!()
}
diff --git a/crates/shirabe-php-shim/src/stream.rs b/crates/shirabe-php-shim/src/stream.rs
index e5e5200..f5582a7 100644
--- a/crates/shirabe-php-shim/src/stream.rs
+++ b/crates/shirabe-php-shim/src/stream.rs
@@ -13,6 +13,8 @@ pub fn stream_get_contents(stream: &PhpResource) -> Option<String> {
}
pub fn stream_resolve_include_path(filename: &str) -> Option<String> {
+ // TODO(phase-d): resolution searches the `include_path` ini setting, which the shim does not
+ // model; checking only the current directory would silently miss configured include paths.
let _ = filename;
todo!()
}
@@ -60,19 +62,49 @@ fn stream_read_remaining(stream: &PhpResource, max_length: Option<i64>) -> Optio
}
}
+// A stream context is modeled as an object holding the two arrays PHP keeps for it: the per-wrapper
+// `options` and the `params`. This is a self-contained value, so it round-trips through the
+// accessors below without any registry.
pub fn stream_context_create(
- _options: &IndexMap<String, PhpMixed>,
- _params: Option<&IndexMap<String, PhpMixed>>,
+ options: &IndexMap<String, PhpMixed>,
+ params: Option<&IndexMap<String, PhpMixed>>,
) -> PhpMixed {
- todo!()
+ let mut context = IndexMap::new();
+ context.insert("options".to_string(), PhpMixed::Array(options.clone()));
+ context.insert(
+ "params".to_string(),
+ PhpMixed::Array(params.cloned().unwrap_or_default()),
+ );
+ PhpMixed::Object(context)
}
-pub fn stream_context_get_options(_stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> {
- todo!()
+pub fn stream_context_get_options(stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> {
+ match stream_or_context {
+ PhpMixed::Object(context) | PhpMixed::Array(context) => context
+ .get("options")
+ .and_then(PhpMixed::as_array)
+ .cloned()
+ .unwrap_or_default(),
+ _ => IndexMap::new(),
+ }
}
-pub fn stream_context_get_params(_stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> {
- todo!()
+pub fn stream_context_get_params(stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> {
+ let (options, params) = match stream_or_context {
+ PhpMixed::Object(context) | PhpMixed::Array(context) => (
+ context.get("options").cloned().unwrap_or_default(),
+ context
+ .get("params")
+ .and_then(PhpMixed::as_array)
+ .cloned()
+ .unwrap_or_default(),
+ ),
+ _ => (PhpMixed::default(), IndexMap::new()),
+ };
+ // PHP exposes the wrapper options under an "options" key alongside the params.
+ let mut result = params;
+ result.insert("options".to_string(), options);
+ result
}
pub fn stream_isatty(stream: PhpResource) -> bool {
@@ -80,6 +112,8 @@ pub fn stream_isatty(stream: PhpResource) -> bool {
}
pub fn stream_get_wrappers() -> Vec<String> {
+ // TODO(phase-d): the registered wrapper set depends on compiled-in extensions and runtime
+ // `stream_wrapper_register` calls; neither is modeled, so a fixed list would be inaccurate.
todo!()
}
diff --git a/crates/shirabe-php-shim/src/zip.rs b/crates/shirabe-php-shim/src/zip.rs
index 08662e6..6b4d87b 100644
--- a/crates/shirabe-php-shim/src/zip.rs
+++ b/crates/shirabe-php-shim/src/zip.rs
@@ -1,9 +1,30 @@
use crate::PhpMixed;
+use crate::{StreamBacking, StreamState};
use indexmap::IndexMap;
+use std::cell::RefCell;
+use zip::write::SimpleFileOptions;
+
+/// Internal backing of an opened `ZipArchive`. PHP's `ZipArchive` multiplexes
+/// reading an existing archive and building a new one through the same handle;
+/// the `zip` crate splits these into `ZipArchive` (read) and `ZipWriter` (write),
+/// so the open mode determines which variant is live.
+#[derive(Debug, Default)]
+enum ZipState {
+ #[default]
+ Closed,
+ Reader(zip::ZipArchive<std::fs::File>),
+ Writer {
+ writer: zip::ZipWriter<std::fs::File>,
+ /// The destination path, retained so `close` can confirm the file exists.
+ path: String,
+ status: String,
+ },
+}
#[derive(Debug)]
pub struct ZipArchive {
pub num_files: i64,
+ state: RefCell<ZipState>,
}
impl Default for ZipArchive {
@@ -14,63 +35,193 @@ impl Default for ZipArchive {
impl ZipArchive {
pub fn new() -> Self {
- todo!()
+ Self {
+ num_files: 0,
+ state: RefCell::new(ZipState::Closed),
+ }
}
- pub fn open(&mut self, _filename: &str, _flags: i64) -> Result<(), i64> {
- todo!()
+ pub fn open(&mut self, filename: &str, flags: i64) -> Result<(), i64> {
+ if flags & Self::CREATE != 0 {
+ let file = match std::fs::File::create(filename) {
+ Ok(f) => f,
+ Err(_) => return Err(Self::ER_OPEN),
+ };
+ *self.state.borrow_mut() = ZipState::Writer {
+ writer: zip::ZipWriter::new(file),
+ path: filename.to_string(),
+ status: String::new(),
+ };
+ self.num_files = 0;
+ Ok(())
+ } else {
+ let file = match std::fs::File::open(filename) {
+ Ok(f) => f,
+ Err(_) => return Err(Self::ER_NOENT),
+ };
+ let archive = match zip::ZipArchive::new(file) {
+ Ok(a) => a,
+ Err(_) => return Err(Self::ER_NOZIP),
+ };
+ self.num_files = archive.len() as i64;
+ *self.state.borrow_mut() = ZipState::Reader(archive);
+ Ok(())
+ }
}
pub fn close(&self) -> bool {
- todo!()
+ let state = std::mem::take(&mut *self.state.borrow_mut());
+ match state {
+ ZipState::Closed => false,
+ ZipState::Reader(_) => true,
+ ZipState::Writer { writer, .. } => writer.finish().is_ok(),
+ }
}
pub fn count(&self) -> i64 {
- todo!()
+ self.num_files
}
- pub fn stat_index(&self, _index: i64) -> Option<IndexMap<String, PhpMixed>> {
- todo!()
+ pub fn stat_index(&self, index: i64) -> Option<IndexMap<String, PhpMixed>> {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return None;
+ };
+ let file = archive.by_index(index as usize).ok()?;
+ let mut stat = IndexMap::new();
+ stat.insert(
+ "name".to_string(),
+ PhpMixed::String(file.name().to_string()),
+ );
+ stat.insert("index".to_string(), PhpMixed::Int(index));
+ stat.insert("crc".to_string(), PhpMixed::Int(file.crc32() as i64));
+ stat.insert("size".to_string(), PhpMixed::Int(file.size() as i64));
+ // PHP exposes the last-modified time as a Unix timestamp. The `zip` crate
+ // only surfaces a 2-second-precision MS-DOS datetime; no consumer reads
+ // this field, so it is reported as 0 rather than reconstructing it.
+ stat.insert("mtime".to_string(), PhpMixed::Int(0));
+ stat.insert(
+ "comp_size".to_string(),
+ PhpMixed::Int(file.compressed_size() as i64),
+ );
+ let comp_method = match file.compression() {
+ zip::CompressionMethod::Stored => 0,
+ zip::CompressionMethod::Deflated => 8,
+ _ => -1,
+ };
+ stat.insert("comp_method".to_string(), PhpMixed::Int(comp_method));
+ Some(stat)
}
- pub fn extract_to(&self, _path: &str) -> bool {
- todo!()
+ pub fn extract_to(&self, path: &str) -> bool {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return false;
+ };
+ archive.extract(path).is_ok()
}
- pub fn locate_name(&self, _name: &str) -> Option<i64> {
- todo!()
+ pub fn locate_name(&self, name: &str) -> Option<i64> {
+ let state = self.state.borrow();
+ let ZipState::Reader(archive) = &*state else {
+ return None;
+ };
+ archive.index_for_name(name).map(|i| i as i64)
}
- pub fn get_from_index(&self, _index: i64) -> Option<String> {
- todo!()
+ pub fn get_from_index(&self, index: i64) -> Option<String> {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return None;
+ };
+ let mut file = archive.by_index(index as usize).ok()?;
+ let mut buf = Vec::new();
+ std::io::Read::read_to_end(&mut file, &mut buf).ok()?;
+ Some(String::from_utf8_lossy(&buf).into_owned())
}
- pub fn get_name_index(&self, _index: i64) -> String {
- todo!()
+ pub fn get_name_index(&self, index: i64) -> String {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return String::new();
+ };
+ match archive.by_index(index as usize) {
+ Ok(file) => file.name().to_string(),
+ Err(_) => String::new(),
+ }
}
- pub fn get_from_name(&self, _name: &str) -> Option<String> {
- todo!()
+ pub fn get_from_name(&self, name: &str) -> Option<String> {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return None;
+ };
+ let mut file = archive.by_name(name).ok()?;
+ let mut buf = Vec::new();
+ std::io::Read::read_to_end(&mut file, &mut buf).ok()?;
+ Some(String::from_utf8_lossy(&buf).into_owned())
}
- pub fn get_stream(&self, _name: &str) -> Option<crate::PhpResource> {
- todo!()
+ pub fn get_stream(&self, name: &str) -> Option<crate::PhpResource> {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Reader(archive) = &mut *state else {
+ return None;
+ };
+ let mut file = archive.by_name(name).ok()?;
+ let mut buf = Vec::new();
+ std::io::Read::read_to_end(&mut file, &mut buf).ok()?;
+ Some(StreamState::new(
+ StreamBacking::Memory(std::io::Cursor::new(buf)),
+ true,
+ false,
+ "r".to_string(),
+ format!("zip://{}", name),
+ ))
}
- pub fn add_empty_dir(&self, _local_name: &str) -> bool {
- todo!()
+ pub fn add_empty_dir(&self, local_name: &str) -> bool {
+ let mut state = self.state.borrow_mut();
+ let ZipState::Writer { writer, .. } = &mut *state else {
+ return false;
+ };
+ writer
+ .add_directory(local_name, SimpleFileOptions::default())
+ .is_ok()
}
- pub fn add_file(&self, _filepath: &str, _local_name: &str) -> bool {
- todo!()
+ pub fn add_file(&self, filepath: &str, local_name: &str) -> bool {
+ let contents = match std::fs::read(filepath) {
+ Ok(c) => c,
+ Err(_) => return false,
+ };
+ let mut state = self.state.borrow_mut();
+ let ZipState::Writer { writer, .. } = &mut *state else {
+ return false;
+ };
+ let options =
+ SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
+ if writer.start_file(local_name, options).is_err() {
+ return false;
+ }
+ std::io::Write::write_all(writer, &contents).is_ok()
}
pub fn set_external_attributes_name(&self, _name: &str, _opsys: i64, _attr: i64) -> bool {
+ // TODO(phase-d): PHP's setExternalAttributesName mutates an already-added
+ // entry's external attributes (e.g. Unix permissions) after addFile. The
+ // `zip` crate fixes external attributes at start_file time via FileOptions
+ // and exposes no API to amend a written entry, so this cannot be faithfully
+ // reproduced without re-architecting add_file. Left unimplemented rather
+ // than silently dropping the permission bits.
todo!()
}
pub fn get_status_string(&self) -> String {
- todo!()
+ let state = self.state.borrow();
+ match &*state {
+ ZipState::Writer { status, .. } => status.clone(),
+ _ => String::new(),
+ }
}
}