From 3a0d9340810a8808d963135a884f50d08442ac67 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 25 Jun 2026 16:27:53 +0900 Subject: test: port 59 autoload/vcs/installer/util/command tests; fix output capture Port autoload_generator (24), bitbucket (14), suggested_packages (11), git_driver (6), archive_manager (3), and a bump command test. Fix the ApplicationTester output-capture root cause (php://memory streams must be readable regardless of fopen mode). Implement posix_getuid/geteuid, the PCRE 'A' anchored modifier, php_strip_whitespace, stream_get_wrappers, is_callable scalars; fix preg_quote angle-bracket escaping and class-map parser regexes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/class_map_generator.rs | 9 +- .../src/php_file_cleaner.rs | 12 +- .../src/php_file_parser.rs | 17 +- crates/shirabe-php-shim/src/fs.rs | 9 +- crates/shirabe-php-shim/src/preg.rs | 96 +- crates/shirabe-php-shim/src/process.rs | 13 +- crates/shirabe-php-shim/src/stream.rs | 25 +- crates/shirabe-php-shim/src/string.rs | 140 +- crates/shirabe-php-shim/src/var.rs | 14 +- crates/shirabe/src/autoload/autoload_generator.rs | 319 +++- crates/shirabe/src/package/handle.rs | 42 + crates/shirabe/src/repository/vcs/git_driver.rs | 6 + crates/shirabe/src/util/git.rs | 2 +- .../tests/autoload/autoload_generator_test.rs | 1739 ++++++++++++++++++-- crates/shirabe/tests/autoload/main.rs | 3 + crates/shirabe/tests/command/bump_command_test.rs | 70 +- crates/shirabe/tests/installer/main.rs | 2 + .../installer/suggested_packages_reporter_test.rs | 200 ++- .../tests/package/archiver/archive_manager_test.rs | 134 +- .../tests/repository/vcs/git_driver_test.rs | 234 ++- crates/shirabe/tests/util/bitbucket_test.rs | 640 ++++++- 21 files changed, 3454 insertions(+), 272 deletions(-) diff --git a/crates/shirabe-class-map-generator/src/class_map_generator.rs b/crates/shirabe-class-map-generator/src/class_map_generator.rs index 5359f12..f591811 100644 --- a/crates/shirabe-class-map-generator/src/class_map_generator.rs +++ b/crates/shirabe-class-map-generator/src/class_map_generator.rs @@ -181,7 +181,14 @@ impl ClassMapGenerator { file_path = format!("{}/{}", cwd, file_path); file_path = Self::normalize_path(&file_path); } else { - file_path = Preg::replace(r"{(?])` anchored (`A`): it consumes the single char + // before the keyword (`.`), requires a word boundary there (`\b`) and forbids + // that char being `$`, `:` or `>`. The `regex` crate has no look-behind, so the + // consumed char plus both guards collapse into one negated class + // `[^a-zA-Z0-9_$:>]` (the `\w` set reproducing `\b`, plus the three operators). + // The possessive quantifiers (`++`, `*+`) are performance-only and become plain + // `+`/`*`. The leftmost-match semantics of `captures_at(.., offset)` stand in for + // the dropped `A` (anchored) modifier, since the keyword is known to sit exactly + // one char past the search offset. pattern: format!( - "{{.\\b(?]){}\\s++[a-zA-Z_\\x7f-\\xff:][a-zA-Z0-9_\\x7f-\\xff:\\-]*+}}Ais", + "{{[^a-zA-Z0-9_$:>]{}\\s+[a-zA-Z_\\x7f-\\xff:][a-zA-Z0-9_\\x7f-\\xff:\\-]*}}is", r#type ), }, diff --git a/crates/shirabe-class-map-generator/src/php_file_parser.rs b/crates/shirabe-class-map-generator/src/php_file_parser.rs index ae98a5c..7d7c722 100644 --- a/crates/shirabe-class-map-generator/src/php_file_parser.rs +++ b/crates/shirabe-class-map-generator/src/php_file_parser.rs @@ -77,12 +77,21 @@ impl PhpFileParser { let contents = p.clean(); drop(p); + // Regex pattern compatibility: + // PHP uses `\b(?])` to require the keyword to start at a word boundary and + // not be preceded by `\`, `$`, `:` or `>` (so `MyClass::class`, `$class`, `\class`, + // `Foo->class` are skipped). The `regex` crate has no look-behind, so `\b` + the negative + // look-behind are fused into a single consuming class `(?:^|[^...])` that excludes both the + // identifier characters (reproducing `\b`) and the four operator characters. The consumed + // separator lands in match group 0 only; the named groups are unaffected. The PCRE + // possessive quantifiers (`++`, `*+`) are performance-only and become plain `+`/`*`. let pattern2 = format!( - r"(?ix) + r"{{ (?: - \b(?])(?Pclass|interface|trait{et}) \s++ (?P[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*+) - | \b(?])(?Pnamespace) (?P\s++[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\s*+\\\\\s*+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+)? \s*+ [\{{;] - )", + (?:^|[^\\$:>a-zA-Z0-9_\x7f-\xff])(?Pclass|interface|trait{et}) \s+ (?P[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*) + | (?:^|[^\\$:>a-zA-Z0-9_\x7f-\xff])(?Pnamespace) (?P\s+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(?:\s*\\\s*[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)*)? \s* [\{{;] + ) + }}ix", et = extra_types ); let mut matches: IndexMap<_, _> = IndexMap::new(); 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 { "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) -> 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) -> 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>) -> 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 { // 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> { - 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![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>, ) -> 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::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 { - 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( where F: FnMut(&[Option]) -> anyhow::Result, { - 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 = 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> = 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> = 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> = 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 { - 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 { - 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 { // 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 { +// 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 { .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 { - // 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 = Vec::with_capacity(n); + let mut i = 0usize; + + let push_space = |out: &mut Vec| { + 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: <<