aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-shim/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/shirabe-php-shim/src')
-rw-r--r--crates/shirabe-php-shim/src/fs.rs9
-rw-r--r--crates/shirabe-php-shim/src/preg.rs96
-rw-r--r--crates/shirabe-php-shim/src/process.rs13
-rw-r--r--crates/shirabe-php-shim/src/stream.rs25
-rw-r--r--crates/shirabe-php-shim/src/string.rs140
-rw-r--r--crates/shirabe-php-shim/src/var.rs14
6 files changed, 260 insertions, 37 deletions
diff --git a/crates/shirabe-php-shim/src/fs.rs b/crates/shirabe-php-shim/src/fs.rs
index ee2bbee..5558f58 100644
--- a/crates/shirabe-php-shim/src/fs.rs
+++ b/crates/shirabe-php-shim/src/fs.rs
@@ -254,12 +254,15 @@ pub fn fopen(file: &str, mode: &str) -> Result<PhpResource, std::io::Error> {
"w" | "a" | "x" | "c" => (false, true),
_ => (true, true), // r+, w+, a+, x+, c+, rw, ...
};
- // php://memory and php://temp are in-memory streams.
+ // php://memory and php://temp are in-memory streams. PHP's memory wrapper ignores the read /
+ // write restrictions implied by the mode: a memory stream opened with "w" is still readable
+ // (this is what Symfony's ApplicationTester relies on -- it opens "php://memory" with "w" and
+ // then rewinds + reads it back). So memory streams are always both readable and writable.
if file == "php://memory" || file.starts_with("php://temp") {
return Ok(StreamState::new(
StreamBacking::Memory(std::io::Cursor::new(Vec::new())),
- readable,
- writable,
+ true,
+ true,
mode.to_string(),
file.to_string(),
));
diff --git a/crates/shirabe-php-shim/src/preg.rs b/crates/shirabe-php-shim/src/preg.rs
index 56aa402..e5bd52e 100644
--- a/crates/shirabe-php-shim/src/preg.rs
+++ b/crates/shirabe-php-shim/src/preg.rs
@@ -25,7 +25,12 @@ impl PregOffsetCaptureMatches {
}
pub fn preg_quote(str: &str, delimiter: Option<char>) -> String {
- const SPECIAL: &str = ".\\+*?[^]$(){}=!<>|:-#";
+ // Regex pattern compatibility:
+ // PHP's preg_quote escapes `<` and `>` (PCRE treats `\<`/`\>` as literals), but the `regex`
+ // crate reads `\<`/`\>` as start-of-word / end-of-word boundary assertions. `<` and `>` are
+ // already literal in the `regex` crate, so they are emitted unescaped to preserve the intended
+ // literal match.
+ const SPECIAL: &str = ".\\+*?[^]$(){}=!|:-#";
let mut out = String::new();
for c in str.chars() {
if c == '\0' {
@@ -43,7 +48,8 @@ pub fn preg_quote(str: &str, delimiter: Option<char>) -> String {
// Returns whether the pattern matched; populates matches[0]=full match, matches[1..]=captures.
// Optional groups that did not participate in the match are stored as None.
pub fn preg_match(pattern: &str, subject: &str, matches: &mut Vec<Option<String>>) -> bool {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
matches.clear();
match re.captures(subject) {
Some(caps) => {
@@ -67,7 +73,8 @@ pub fn preg_split(pattern: &str, subject: &str) -> Vec<String> {
// PREG_PATTERN_ORDER: the outer vec is indexed by capture group, the inner by
// match occurrence. Non-participating groups are reported as "".
pub fn preg_match_all(pattern: &str, subject: &str) -> Vec<Vec<String>> {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let group_count = re.captures_len();
let mut groups: Vec<Vec<String>> = vec![Vec::new(); group_count];
for caps in re.captures_iter(subject) {
@@ -89,7 +96,8 @@ pub fn preg_match_all_set_order(
subject: &str,
matches: &mut Vec<Vec<String>>,
) -> usize {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let mut rows: Vec<Vec<String>> = Vec::new();
for caps in re.captures_iter(subject) {
rows.push(php_match_row(&caps));
@@ -100,7 +108,8 @@ pub fn preg_match_all_set_order(
}
pub fn preg_grep(pattern: &str, input: &[String]) -> Vec<String> {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
input.iter().filter(|s| re.is_match(s)).cloned().collect()
}
@@ -109,7 +118,8 @@ pub fn preg_match_all_offset_capture(
subject: &str,
matches: &mut PregOffsetCaptureMatches,
) -> usize {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let group_count = re.captures_len();
matches.groups = vec![Vec::new(); group_count];
@@ -139,7 +149,8 @@ pub fn preg_replace_callback<F>(
where
F: FnMut(&[Option<String>]) -> anyhow::Result<String>,
{
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let mut out: Vec<u8> = Vec::new();
let mut last = 0;
for caps in re.captures_iter(subject) {
@@ -165,9 +176,18 @@ pub fn preg_match2(
flags: i64,
offset: usize,
) -> bool {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0;
- let caps = re.captures_at(subject, offset);
+ // An anchored (`A`) pattern must match starting exactly at `offset`; the `regex` crate cannot
+ // anchor a `captures_at` search, so search the sub-slice beginning at `offset` and require the
+ // match to start at its head.
+ let caps = if anchored {
+ re.captures(&subject[offset..])
+ .filter(|c| c.get(0).map(|m| m.start()) == Some(0))
+ } else {
+ re.captures_at(subject, offset)
+ };
matches.clear();
if let Some(caps) = &caps {
@@ -185,7 +205,8 @@ pub fn preg_match_all2(
flags: i64,
offset: usize,
) -> usize {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0;
let group_count = re.captures_len();
let names: Vec<Option<&str>> = re.capture_names().collect();
@@ -223,7 +244,8 @@ pub fn preg_match_all_offset_capture2(
flags: i64,
offset: usize,
) -> usize {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0;
let group_count = re.captures_len();
let names: Vec<Option<&str>> = re.capture_names().collect();
@@ -260,7 +282,8 @@ pub fn preg_replace2(
limit: i64,
count: Option<&mut usize>,
) -> String {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let limit = if limit < 0 {
usize::MAX
} else {
@@ -298,7 +321,8 @@ pub fn preg_replace_callback2<
count: Option<&mut usize>,
flags: i64,
) -> String {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0;
let names: Vec<Option<&str>> = re.capture_names().collect();
let limit = if limit < 0 {
@@ -330,7 +354,8 @@ pub fn preg_replace_callback2<
}
pub fn preg_split2(pattern: &str, subject: &str, limit: i64, flags: i64) -> Vec<String> {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let no_empty = flags & PREG_SPLIT_NO_EMPTY != 0;
let delim_capture = flags & PREG_SPLIT_DELIM_CAPTURE != 0;
// `limit` counts the resulting pieces; a non-positive value means no limit.
@@ -371,7 +396,8 @@ pub fn preg_split2(pattern: &str, subject: &str, limit: i64, flags: i64) -> Vec<
}
pub fn preg_grep2(pattern: &str, array: &[&str], flags: i64) -> Vec<String> {
- let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
+ let (re, _anchored) =
+ compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"));
let invert = flags & PREG_GREP_INVERT != 0;
array
.iter()
@@ -386,7 +412,34 @@ pub fn preg_grep2(pattern: &str, array: &[&str], flags: i64) -> Vec<String> {
// lookaround, backreferences) are not supported by `regex` and must be avoided
// in the caller's pattern.
// TODO(phase-c): replace with a faithful PCRE engine to restore full semantics.
-fn compile_php_pattern(pattern: &str) -> anyhow::Result<regex::Regex> {
+// PCRE treats `\<` and `\>` as escaped literal `<`/`>`, but the `regex` crate
+// reads them as start/end-of-word boundary assertions. Rewrite those escapes to
+// the literal characters so PCRE-sourced patterns (e.g. anything run through
+// `preg_quote`, which escapes `<` and `>`) keep their original meaning. A `\\`
+// escapes the following backslash, so `\\<` is left untouched.
+fn translate_pcre_literals(inner: &str) -> String {
+ let mut out = String::with_capacity(inner.len());
+ let mut chars = inner.chars().peekable();
+ while let Some(c) = chars.next() {
+ if c == '\\' {
+ match chars.peek() {
+ Some('<') | Some('>') => {
+ out.push(chars.next().unwrap());
+ }
+ Some('\\') => {
+ out.push('\\');
+ out.push(chars.next().unwrap());
+ }
+ _ => out.push('\\'),
+ }
+ } else {
+ out.push(c);
+ }
+ }
+ out
+}
+
+fn compile_php_pattern(pattern: &str) -> anyhow::Result<(regex::Regex, bool)> {
let delimiter = pattern
.chars()
.next()
@@ -412,13 +465,20 @@ fn compile_php_pattern(pattern: &str) -> anyhow::Result<regex::Regex> {
.filter(|c| matches!(c, 'i' | 'x' | 's' | 'm'))
.collect();
+ // PCRE's `A` (PCRE_ANCHORED) modifier requires the match to start exactly at the search offset.
+ // The `regex` crate has no per-search anchoring, so the offset-based callers
+ // (`preg_match2`/`preg_match_all2`) honour it by searching a sub-slice that begins at the offset;
+ // here we only surface the flag.
+ let anchored = modifiers.contains('A');
+
+ let inner = translate_pcre_literals(inner);
let translated = if flags.is_empty() {
- inner.to_string()
+ inner
} else {
format!("(?{flags}){inner}")
};
- Ok(regex::Regex::new(&translated)?)
+ Ok((regex::Regex::new(&translated)?, anchored))
}
// Expands a PHP preg replacement template against `caps`, appending bytes to
diff --git a/crates/shirabe-php-shim/src/process.rs b/crates/shirabe-php-shim/src/process.rs
index 402ad8b..b652bed 100644
--- a/crates/shirabe-php-shim/src/process.rs
+++ b/crates/shirabe-php-shim/src/process.rs
@@ -371,14 +371,19 @@ pub fn pcntl_signal_get_handler(_signal: i64) -> PhpMixed {
todo!()
}
+unsafe extern "C" {
+ fn getuid() -> u32;
+ fn geteuid() -> u32;
+}
+
pub fn posix_getuid() -> i64 {
- // TODO(phase-d): getuid(2) is not reachable without a libc/syscall crate.
- todo!()
+ // getuid(2) cannot fail; libc is already linked into every binary, so no extra crate is needed.
+ (unsafe { getuid() }) as i64
}
pub fn posix_geteuid() -> i64 {
- // TODO(phase-d): geteuid(2) is not reachable without a libc/syscall crate.
- todo!()
+ // geteuid(2) cannot fail; libc is already linked into every binary, so no extra crate is needed.
+ (unsafe { geteuid() }) as i64
}
pub fn posix_getpwuid(_uid: i64) -> PhpMixed {
diff --git a/crates/shirabe-php-shim/src/stream.rs b/crates/shirabe-php-shim/src/stream.rs
index f5582a7..a8ccbe5 100644
--- a/crates/shirabe-php-shim/src/stream.rs
+++ b/crates/shirabe-php-shim/src/stream.rs
@@ -112,9 +112,28 @@ 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!()
+ // The full registered set depends on compiled-in extensions and runtime
+ // `stream_wrapper_register` calls, which are not modeled. We return the wrappers always
+ // registered by a stock PHP CLI build (the environment Composer's tests assume): the core
+ // `php`/`file`/`glob`/`data` wrappers, the always-present `phar` wrapper, plus the
+ // `compress.*`/`http`/`ftp` family. Consumers only use this to recognise `wrapper://` prefixes.
+ [
+ "https",
+ "ftps",
+ "compress.zlib",
+ "compress.bzip2",
+ "php",
+ "file",
+ "glob",
+ "data",
+ "http",
+ "ftp",
+ "phar",
+ "zip",
+ ]
+ .iter()
+ .map(|s| s.to_string())
+ .collect()
}
/// PHP `stream_copy_to_stream()`: copy the remaining bytes of `source` into `dest`, returning the
diff --git a/crates/shirabe-php-shim/src/string.rs b/crates/shirabe-php-shim/src/string.rs
index 48c72f4..9a82fe8 100644
--- a/crates/shirabe-php-shim/src/string.rs
+++ b/crates/shirabe-php-shim/src/string.rs
@@ -1327,10 +1327,142 @@ pub fn addcslashes(_string: &str, _charlist: &str) -> String {
String::from_utf8_lossy(&out).into_owned()
}
-pub fn php_strip_whitespace(_path: &str) -> String {
- // TODO(phase-d): PHP strips comments and redundant whitespace using the PHP tokenizer; no PHP
- // tokenizer is available in the shim.
- todo!()
+pub fn php_strip_whitespace(path: &str) -> String {
+ // PHP `php_strip_whitespace()` tokenizes the source and re-emits it with comments removed and
+ // each run of whitespace collapsed to a single space. There is no PHP tokenizer in the shim, so
+ // this is a hand-written lexer that reproduces the observable effect for the cases the class-map
+ // generator depends on: it preserves single-quoted, double-quoted, backtick and heredoc/nowdoc
+ // string contents verbatim while dropping `//`, `#` and `/* */` comments and squeezing
+ // whitespace. On any read failure it returns an empty string, mirroring `@php_strip_whitespace`.
+ let contents = match std::fs::read(path) {
+ Ok(bytes) => bytes,
+ Err(_) => return String::new(),
+ };
+
+ let b = contents;
+ let n = b.len();
+ let mut out: Vec<u8> = Vec::with_capacity(n);
+ let mut i = 0usize;
+
+ let push_space = |out: &mut Vec<u8>| {
+ if !out.is_empty() && *out.last().unwrap() != b' ' {
+ out.push(b' ');
+ }
+ };
+
+ while i < n {
+ let c = b[i];
+
+ // Line comments: // ... and # ...
+ if c == b'/' && i + 1 < n && b[i + 1] == b'/' {
+ i += 2;
+ while i < n && b[i] != b'\n' {
+ i += 1;
+ }
+ push_space(&mut out);
+ continue;
+ }
+ if c == b'#' {
+ i += 1;
+ while i < n && b[i] != b'\n' {
+ i += 1;
+ }
+ push_space(&mut out);
+ continue;
+ }
+ // Block comments: /* ... */
+ if c == b'/' && i + 1 < n && b[i + 1] == b'*' {
+ i += 2;
+ while i + 1 < n && !(b[i] == b'*' && b[i + 1] == b'/') {
+ i += 1;
+ }
+ i += 2;
+ push_space(&mut out);
+ continue;
+ }
+ // String literals: '...', "...", `...`
+ if c == b'\'' || c == b'"' || c == b'`' {
+ out.push(c);
+ i += 1;
+ while i < n {
+ let d = b[i];
+ out.push(d);
+ if d == b'\\' && i + 1 < n {
+ out.push(b[i + 1]);
+ i += 2;
+ continue;
+ }
+ i += 1;
+ if d == c {
+ break;
+ }
+ }
+ continue;
+ }
+ // Heredoc/Nowdoc: <<<LABEL ... LABEL
+ if c == b'<' && i + 2 < n && b[i + 1] == b'<' && b[i + 2] == b'<' {
+ let mut j = i + 3;
+ while j < n && (b[j] == b' ' || b[j] == b'\t') {
+ j += 1;
+ }
+ let nowdoc = j < n && b[j] == b'\'';
+ let quoted = j < n && (b[j] == b'\'' || b[j] == b'"');
+ if quoted {
+ j += 1;
+ }
+ let label_start = j;
+ while j < n && (b[j].is_ascii_alphanumeric() || b[j] == b'_') {
+ j += 1;
+ }
+ let label = b[label_start..j].to_vec();
+ if !label.is_empty() {
+ let _ = nowdoc;
+ // Emit verbatim from the opening marker until a line whose trimmed start matches the
+ // closing label.
+ let body_start = i;
+ let mut k = j;
+ // skip to end of the opening line
+ while k < n && b[k] != b'\n' {
+ k += 1;
+ }
+ let mut closed = n;
+ while k < n {
+ k += 1; // move past '\n'
+ let line_start = k;
+ let mut m = line_start;
+ while m < n && (b[m] == b' ' || b[m] == b'\t') {
+ m += 1;
+ }
+ if b[m..].starts_with(&label) {
+ let after = m + label.len();
+ let boundary =
+ after >= n || !(b[after].is_ascii_alphanumeric() || b[after] == b'_');
+ if boundary {
+ closed = after;
+ break;
+ }
+ }
+ while k < n && b[k] != b'\n' {
+ k += 1;
+ }
+ }
+ out.extend_from_slice(&b[body_start..closed.min(n)]);
+ i = closed.min(n);
+ continue;
+ }
+ }
+ // Whitespace runs collapse to a single space.
+ if c == b' ' || c == b'\t' || c == b'\r' || c == b'\n' {
+ i += 1;
+ push_space(&mut out);
+ continue;
+ }
+
+ out.push(c);
+ i += 1;
+ }
+
+ String::from_utf8_lossy(&out).into_owned()
}
pub fn hexdec(_s: &str) -> i64 {
diff --git a/crates/shirabe-php-shim/src/var.rs b/crates/shirabe-php-shim/src/var.rs
index adaacec..3fba9d6 100644
--- a/crates/shirabe-php-shim/src/var.rs
+++ b/crates/shirabe-php-shim/src/var.rs
@@ -120,11 +120,15 @@ pub fn is_numeric(value: &PhpMixed) -> bool {
}
}
-pub fn is_callable(_value: &PhpMixed) -> bool {
- // TODO(phase-d): PHP is_callable() checks whether the value names an existing function/method or
- // is a closure. PhpMixed has no callable variant and the shim has no function registry, so
- // callability cannot be determined.
- todo!()
+pub fn is_callable(value: &PhpMixed) -> bool {
+ match value {
+ // Scalars and null are never callable in PHP.
+ PhpMixed::Null | PhpMixed::Bool(_) | PhpMixed::Int(_) | PhpMixed::Float(_) => false,
+ // TODO(phase-d): PHP is_callable() checks whether a string names an existing function, or an
+ // array/object resolves to a method/__invoke. PhpMixed has no callable variant and the shim
+ // has no function/method registry, so callability of these cannot be determined.
+ _ => todo!(),
+ }
}
pub fn is_object(_value: &PhpMixed) -> bool {