-
-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathphp_test.go
More file actions
1174 lines (1110 loc) · 46.7 KB
/
php_test.go
File metadata and controls
1174 lines (1110 loc) · 46.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main_test
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"runtime/debug"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/KarpelesLab/goro/core/compiler"
"github.com/KarpelesLab/goro/core/ini"
"github.com/KarpelesLab/goro/core/phpctx"
"github.com/KarpelesLab/goro/core/phperr"
"github.com/KarpelesLab/goro/core/phpobj"
"github.com/KarpelesLab/goro/core/phpv"
"github.com/KarpelesLab/goro/core/tokenizer"
"github.com/andreyvit/diff"
)
// Currently focusing on lang tests, change variable to run other tests
var TestsPath = func() string {
if v := os.Getenv("GORO_TEST_PATH"); v != "" {
return v
}
return "test"
}()
// maxTestOutputSize caps the output buffer per test to prevent OOM crashes
// from infinite-output tests (e.g., recursive json_encode). 10 MB is generous
// for any normal test.
const maxTestOutputSize = 10 * 1024 * 1024
// limitedBuffer wraps a bytes.Buffer and silently discards writes once the
// buffer exceeds maxTestOutputSize. This prevents runaway tests from causing
// OOM crashes that kill the entire test suite.
type limitedBuffer struct {
buf bytes.Buffer
limited bool
}
func (lb *limitedBuffer) Write(p []byte) (int, error) {
if lb.limited {
return len(p), nil // silently discard
}
if lb.buf.Len()+len(p) > maxTestOutputSize {
lb.limited = true
return len(p), nil
}
return lb.buf.Write(p)
}
func (lb *limitedBuffer) Bytes() []byte { return lb.buf.Bytes() }
func (lb *limitedBuffer) Len() int { return lb.buf.Len() }
func (lb *limitedBuffer) String() string { return lb.buf.String() }
// truncatedDiff computes a diff but truncates inputs to avoid O(n²) blowup
// on large outputs with many differences.
func truncatedDiff(expected, actual string) string {
const maxLines = 80
truncate := func(s string) string {
lines := strings.SplitN(s, "\n", maxLines+1)
if len(lines) > maxLines {
return strings.Join(lines[:maxLines], "\n") + "\n... (truncated)"
}
return s
}
return diff.LineDiff(truncate(expected), truncate(actual))
}
type phptest struct {
f *os.File
reader *bufio.Reader
output *limitedBuffer
name string
path string
req *http.Request
iniRaw string // raw INI settings from --INI-- section
cliMode bool // true when test has --ARGS-- (run as CLI, not web)
stdinData []byte // data from --STDIN-- section
xfail string // XFAIL reason, if set
p *phpctx.Process
t *testing.T
}
type skipError struct {
reason string
}
func (s skipError) Error() string {
if s.reason != "" {
return "test skipped: " + s.reason
}
return "test skipped"
}
var skipTest = skipError{}
func (p *phptest) handlePart(part string, b *bytes.Buffer) error {
switch part {
case "TEST":
testName := strings.TrimSpace(b.String())
p.name += ": " + testName
return nil
case "CREDITS":
// is there something we should do with this?
return nil
case "GET":
p.req.URL.RawQuery = strings.TrimRight(b.String(), "\r\n")
return nil
case "POST":
// we need a new request with the post data
p.req = httptest.NewRequest("POST", "/"+path.Base(p.path), bytes.NewBuffer(bytes.TrimRight(b.Bytes(), "\r\n")))
p.req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return nil
case "FILE", "FILEEOF":
// Fix permissions on any files/dirs in the test tree that may have been
// left by previous tests with restrictive permissions (e.g., chmod tests).
{
cleanDir := filepath.Dir(func() string { p, _ := filepath.Abs(p.path); return p }())
// Always ensure the test root and all parent dirs up to TestsPath are accessible
if absTestsPath, err := filepath.Abs(TestsPath); err == nil {
for d := cleanDir; len(d) >= len(absTestsPath); d = filepath.Dir(d) {
os.Chmod(d, 0755)
if d == absTestsPath {
break
}
}
}
if entries, err := os.ReadDir(cleanDir); err == nil {
for _, entry := range entries {
fp := filepath.Join(cleanDir, entry.Name())
if entry.IsDir() {
os.Chmod(fp, 0755)
} else {
if info, err := entry.Info(); err == nil && info.Mode().Perm() == 0 {
os.Chmod(fp, 0644)
}
}
}
}
}
// pass data to the engine
var g *phpctx.Global
if p.cliMode {
g = phpctx.NewGlobal(context.Background(), p.p, ini.New())
} else {
g = phpctx.NewGlobalReq(p.req, p.p, ini.New())
}
// Set a 10-second execution deadline per test to prevent
// infinite loops from blocking the entire suite.
g.SetDeadline(time.Now().Add(15 * time.Second))
g.SetOutput(p.output)
// Apply --STDIN-- data if present
if p.stdinData != nil {
g.SetStdin(bytes.NewReader(p.stdinData))
}
// Apply --INI-- settings after global context is created (after defaults)
needsReinit := false
if p.iniRaw != "" {
if err := g.IniConfig.Parse(g, strings.NewReader(p.iniRaw)); err != nil {
return err
}
// Only reinit superglobals if INI contains settings that affect them
for _, key := range []string{"variables_order", "register_argc_argv", "enable_post_data_reading", "disable_functions", "post_max_size", "max_input_nesting_level", "file_uploads", "upload_max_filesize", "max_file_uploads", "upload_tmp_dir"} {
if strings.Contains(p.iniRaw, key) {
needsReinit = true
break
}
}
}
// Always sync MemMgr limit with the INI memory_limit value
g.ApplyMaxMemoryLimit()
if needsReinit {
g.ReinitSuperglobals()
}
// Validate date.timezone: emit startup warning if empty or invalid
g.ValidateDateTimezone()
// Convert to absolute path so __DIR__ and include paths work correctly
absPath, _ := filepath.Abs(p.path)
g.Chdir(phpv.ZString(filepath.Dir(absPath))) // chdir execution to path
// Use .php extension for the script filename (tests expect .php, not .phpt)
scriptPath := strings.TrimSuffix(absPath, "t")
// Write the .php file to disk so functions like show_source(__FILE__) can re-read it.
// Remove first in case a stale copy from a crashed run has restrictive permissions.
os.Chmod(scriptPath, 0644) // fix perms if needed
os.Remove(scriptPath)
os.WriteFile(scriptPath, b.Bytes(), 0644)
defer os.Remove(scriptPath)
shortOpenTag := bool(g.GetConfig("short_open_tag", phpv.ZBool(true).ZVal()).AsBool(g))
t := tokenizer.NewLexerWithShortTag(b, scriptPath, shortOpenTag)
defer t.Close()
// Compile with timeout: run in goroutine so we can enforce deadline
type compileResult struct {
code phpv.Runnable
err error
}
compileCh := make(chan compileResult, 1)
go func() {
code, err := compiler.Compile(g, t)
compileCh <- compileResult{code, err}
}()
var c phpv.Runnable
var compileErr error
timer := time.NewTimer(5 * time.Second)
select {
case result := <-compileCh:
timer.Stop()
c = result.code
compileErr = result.err
case <-timer.C:
t.Close() // kill the lexer goroutine
// Force GC to reclaim any memory the compile goroutine allocated
runtime.GC()
debug.FreeOSMemory()
return fmt.Errorf("compilation timed out (possible infinite loop)")
}
if compileErr != nil {
// Filter exit errors from compile (e.g., E_COMPILE_ERROR already output)
compileErr = phpv.FilterExitError(compileErr)
if compileErr != nil {
// Handle parse errors and compile errors by outputting them
if phpErr, ok := compileErr.(*phpv.PhpError); ok && (phpErr.Code == phpv.E_PARSE || phpErr.Code == phpv.E_COMPILE_ERROR || phpErr.Code == phpv.E_ERROR) {
g.LogError(phpErr)
g.Close()
return nil
}
return compileErr
}
g.Close()
return nil
}
var retVal *phpv.ZVal
var err error
retVal, err = c.Run(g)
retVal, err = phperr.CatchReturn(retVal, err)
_ = retVal
err = phpv.FilterExitError(err)
// Convert break/continue outside loop to a fatal error (matching PHP behavior)
if br, ok := phpv.UnwrapError(err).(*phperr.PhpBreak); ok {
if br.Initial > 1 {
err = &phpv.PhpError{Err: fmt.Errorf("Cannot 'break' %d levels", br.Initial), Loc: br.L, Code: phpv.E_ERROR}
} else {
err = &phpv.PhpError{Err: fmt.Errorf("'break' not in the 'loop' or 'switch' context"), Loc: br.L, Code: phpv.E_ERROR}
}
} else if cr, ok := phpv.UnwrapError(err).(*phperr.PhpContinue); ok {
if cr.Initial > 1 {
err = &phpv.PhpError{Err: fmt.Errorf("Cannot 'continue' %d levels", cr.Initial), Loc: cr.L, Code: phpv.E_ERROR}
} else {
err = &phpv.PhpError{Err: fmt.Errorf("'continue' not in the 'loop' or 'switch' context"), Loc: cr.L, Code: phpv.E_ERROR}
}
}
if err != nil {
// Handle uncaught exceptions via user exception handler
err = g.HandleUncaughtException(err)
}
// Output fatal errors through the global output (which may be buffered)
// so output buffer callbacks can process them (PHP behavior).
if err != nil {
htmlErrors := bool(g.GetConfig("html_errors", phpv.ZBool(false).ZVal()).AsBool(g))
if ex, ok := err.(*phperr.PhpThrow); ok {
// Special handling for ParseError: PHP displays these as
// "Parse error: <message> in <file> on line <line>"
// instead of the usual "Fatal error: Uncaught ParseError: ..." format
if ex.Obj.GetClass().InstanceOf(phpobj.ParseError) {
message := ex.Obj.HashTable().GetString("message").String()
fileLoc := ex.ThrownFile()
lineLoc := ex.ThrownLine()
if htmlErrors {
fmt.Fprintf(g, "<br />\n<b>Parse error</b>: %s in <b>%s</b> on line <b>%d</b><br />\n", message, fileLoc, lineLoc)
} else {
fmt.Fprintf(g, "\nParse error: %s in %s on line %d\n", message, fileLoc, lineLoc)
}
} else if htmlErrors {
traceStr, replacement := ex.ErrorTrace(g)
displayEx := ex
if replacement != nil {
displayEx = replacement
}
fmt.Fprintf(g, "<br />\n<b>Fatal error</b>: %s\n thrown in <b>%s</b> on line <b>%d</b><br />\n", traceStr, displayEx.ThrownFile(), displayEx.ThrownLine())
} else {
traceStr, replacement := ex.ErrorTrace(g)
displayEx := ex
if replacement != nil {
displayEx = replacement
}
fmt.Fprintf(g, "\nFatal error: %s\n thrown in %s on line %d\n", traceStr, displayEx.ThrownFile(), displayEx.ThrownLine())
// Set LastError so that error_get_last() in shutdown functions returns this fatal error.
// The message is "trace\n thrown" (truncated at "thrown"), matching PHP's behavior.
msg := traceStr + "\n thrown"
g.LastError = &phpv.PhpError{
Err: fmt.Errorf("%s", msg),
Code: phpv.E_ERROR,
Loc: &phpv.Loc{Filename: displayEx.ThrownFile(), Line: displayEx.ThrownLine()},
}
}
err = nil
} else if timeout, ok := phperr.CatchTimeout(err).(*phperr.PhpTimeout); ok && timeout != nil {
fmt.Fprint(g, "\n"+timeout.String())
err = nil
} else if phpErr, ok := err.(*phpv.PhpError); ok && (phpErr.Code == phpv.E_ERROR || phpErr.Code == phpv.E_COMPILE_ERROR) {
// Clean buffered output before writing the fatal error,
// so only the error message passes through the callback
// (not the previously buffered output).
g.CleanBuffers()
if phpErr.Loc != nil {
if htmlErrors {
fmt.Fprintf(g, "<br />\n<b>Fatal error</b>: %s in <b>%s</b> on line <b>%d</b><br />\n", phpErr.Err.Error(), phpErr.Loc.Filename, phpErr.Loc.Line)
} else {
fmt.Fprintf(g, "\nFatal error: %s in %s on line %d\n", phpErr.Err.Error(), phpErr.Loc.Filename, phpErr.Loc.Line)
}
} else {
if htmlErrors {
fmt.Fprintf(g, "<br />\n<b>Fatal error</b>: %s<br />\n", phpErr.Err.Error())
} else {
fmt.Fprintf(g, "\nFatal error: %s\n", phpErr.Err.Error())
}
}
err = nil
}
}
g.RunShutdownFunctions()
// Send headers if not already sent (fires header_register_callback callbacks)
if hc := g.HeaderContext(); hc != nil && !hc.Sent {
hc.SendHeaders(g)
}
closeErr := g.Close()
if err == nil && closeErr != nil {
// Handle fatal errors from output buffer callbacks during close
if phpErr, ok := closeErr.(*phpv.PhpError); ok && (phpErr.Code == phpv.E_ERROR || phpErr.Code == phpv.E_COMPILE_ERROR) {
if phpErr.Loc != nil {
fmt.Fprintf(p.output, "\nFatal error: %s in %s on line %d\n", phpErr.Err.Error(), phpErr.Loc.Filename, phpErr.Loc.Line)
} else {
fmt.Fprintf(p.output, "\nFatal error: %s\n", phpErr.Err.Error())
}
closeErr = nil
} else if ex, ok := closeErr.(*phperr.PhpThrow); ok {
traceStr, replacement := ex.ErrorTrace(g)
displayEx := ex
if replacement != nil {
displayEx = replacement
}
fmt.Fprintf(p.output, "\nFatal error: %s\n thrown in %s on line %d\n", traceStr, displayEx.ThrownFile(), displayEx.ThrownLine())
closeErr = nil
}
if closeErr != nil {
err = closeErr
}
}
return err
case "EXPECT":
// compare p.output with b
out := bytes.TrimSpace(p.output.Bytes())
exp := bytes.TrimSpace(b.Bytes())
// Normalize \r\n to \n (PHP test runner does this)
out = bytes.ReplaceAll(out, []byte("\r\n"), []byte("\n"))
exp = bytes.ReplaceAll(exp, []byte("\r\n"), []byte("\n"))
if bytes.Compare(out, exp) != 0 {
return fmt.Errorf("output not as expected!\n%s", truncatedDiff(string(exp), string(out)))
}
return nil
case "SKIPIF":
t := tokenizer.NewLexer(b, p.path)
g := phpctx.NewGlobal(context.Background(), p.p, ini.New())
output := &bytes.Buffer{}
g.SetOutput(output)
c, err := compiler.Compile(g, t)
if err != nil {
// If SKIPIF code can't compile (e.g., missing include file), skip the test
return skipError{reason: "SKIPIF compile error: " + err.Error()}
}
_, err = c.Run(g)
err = phpv.FilterExitError(err)
if err != nil {
// If SKIPIF code errors at runtime, skip the test (PHP's run-tests does the same)
return skipError{reason: "SKIPIF runtime error: " + err.Error()}
}
if bytes.HasPrefix(bytes.ToLower(output.Bytes()), []byte("skip")) {
return skipError{reason: "SKIPIF: " + strings.TrimSpace(string(output.Bytes()))}
}
return nil
case "EXPECTF":
// EXPECTF is like EXPECT but allows format specifiers
out := bytes.TrimSpace(p.output.Bytes())
exp := bytes.TrimSpace(b.Bytes())
// Normalize \r\n to \n (PHP test runner does this)
out = bytes.ReplaceAll(out, []byte("\r\n"), []byte("\n"))
exp = bytes.ReplaceAll(exp, []byte("\r\n"), []byte("\n"))
// Convert EXPECTF pattern to a regex
re := expectfToRegex(string(exp))
// Sanitize non-UTF8 bytes in output for regex matching
outStr := sanitizeForRegex(string(out))
matched, err := regexp.MatchString("(?s)^"+re+"$", outStr)
if err != nil {
return fmt.Errorf("invalid EXPECTF pattern: %s", err)
}
if !matched {
return fmt.Errorf("output not as expected!\n%s", truncatedDiff(string(exp), string(out)))
}
return nil
case "EXPECTREGEX":
out := bytes.TrimSpace(p.output.Bytes())
exp := strings.TrimSpace(b.String())
matched, err := regexp.MatchString("(?s)^"+exp+"$", string(out))
if err != nil {
return fmt.Errorf("invalid EXPECTREGEX pattern: %s", err)
}
if !matched {
return fmt.Errorf("output not as expected!\n%s", truncatedDiff(exp, string(out)))
}
return nil
case "INI":
// Parse INI settings. Skip tests that require features we definitely
// can't support. Accept everything else and let the test run.
// INI settings that always cause a skip (feature not implemented at all)
unsupported := map[string]bool{
// file_uploads: accepted — implemented in parsePost() multipart handling
// enable_post_data_reading: implemented - when 0, $_POST/$_FILES are empty but php://input works
// post_max_size: handled by valueDependent - skip only when non-zero
// upload_max_filesize: implemented — enforced in parsePost() file upload handling
// upload_tmp_dir: implemented — uses os.TempDir() as default
// max_file_uploads: implemented — enforced in parsePost() file upload handling
// memory_limit: stored/retrieved via ini_get/ini_set; enforcement not implemented but tests don't require it
"hard_timeout": true, // hard timeout not implemented
"session.auto_start": true, // sessions not implemented
// filter.default=unsafe_raw is a no-op (no filtering), safe to accept
// open_basedir: implemented - checks file access against allowed directories
// precision and serialize_precision are implemented in core/phpv/ztype.go
// register_argc_argv: implemented - controls argv/argc in $_SERVER
// variables_order: implemented in doGPC() - controls which superglobals are populated
// highlight.*: implemented - syntax highlighting output matches PHP 8 format
// max_input_nesting_level: implemented in setUrlValueToArray (drops over-nested params)
// max_input_vars: limit on input parsing, tests use 1000 which is well above typical test needs
// short_open_tag: implemented in tokenizer - controls whether <? without php/= opens PHP mode
// auto_prepend_file: implemented in RunFile - includes file before main script
// disable_functions: implemented - removes named functions from available list
"allow_url_fopen": true, // tests using this need HTTP server helpers we don't have
"default_charset": true, // charset-aware functions (htmlentities etc) not fully implemented
"error_log_mode": true, // log mode not implemented
// report_memleaks: deprecated directive, handled in ini parser
// sys_temp_dir is implemented in ext/standard/fs.go:fncSysGetTempDir
// date.timezone is handled by the date extension's ini settings
"opcache.save_comments": true, // needs ReflectionClass doc comments support
"docref_root": true, // needs HTML error link formatting
// arg_separator.input is implemented in ext/standard/urlenc.go
}
// INI settings that only block when set to a non-default/active value
// e.g., zlib.output_compression=0 is "off" (default) → safe
valueDependent := map[string]func(string) bool{
"zlib.output_compression": func(v string) bool {
lv := strings.ToLower(strings.TrimSpace(v))
return lv != "0" && lv != "off" && lv != "false" && lv != "no" && lv != ""
},
"post_max_size": func(v string) bool {
// post_max_size is now enforced in the runtime.
// Accept all values — enforcement is handled by parsePost().
return false
},
"memory_limit": func(v string) bool {
// memory_limit is now enforced via runtime memory checking
// in the Tick() handler. Accept all values.
return false
},
// file_uploads: accepted for all values — tests needing upload
// infrastructure also set upload_max_filesize or upload_tmp_dir
}
// Save content before scanning (scanner consumes the buffer)
iniContent := b.String()
scanner := bufio.NewScanner(strings.NewReader(iniContent))
hasUnsupported := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == ';' {
continue
}
pos := strings.IndexByte(line, '=')
if pos == -1 {
continue
}
k := strings.TrimSpace(line[:pos])
v := ""
if pos+1 < len(line) {
v = strings.TrimSpace(line[pos+1:])
}
if unsupported[k] {
hasUnsupported = true
break
}
if check, ok := valueDependent[k]; ok && check(v) {
hasUnsupported = true
break
}
}
if hasUnsupported {
return skipError{reason: "unsupported INI"}
}
// Substitute {PWD} with test file directory (matches PHP run-tests.php)
testDir := filepath.Dir(p.path)
if absDir, err := filepath.Abs(testDir); err == nil {
testDir = absDir
}
iniContent = strings.ReplaceAll(iniContent, "{PWD}", testDir)
p.iniRaw = iniContent
return nil
case "EXTENSIONS":
// Check that all required extensions are loaded
for _, line := range strings.Split(strings.TrimSpace(b.String()), "\n") {
ext := strings.TrimSpace(line)
if ext == "" {
continue
}
if !phpctx.HasExt(ext) {
return skipError{reason: "missing extension: " + ext}
}
}
return nil
case "XFAIL":
p.xfail = strings.TrimSpace(b.String())
return nil
case "CLEAN":
// CLEAN runs after the test to clean up temp files/dirs
g := phpctx.NewGlobal(context.Background(), p.p, ini.New())
g.SetOutput(io.Discard)
absPath, _ := filepath.Abs(p.path)
g.Chdir(phpv.ZString(filepath.Dir(absPath)))
// PHP runs CLEAN sections from <test>.clean.php, not <test>.php
// This matters because CLEAN scripts use basename(__FILE__, ".clean.php")
cleanFileName := strings.TrimSuffix(absPath, ".phpt") + ".clean.php"
t := tokenizer.NewLexer(b, cleanFileName)
if c, err := compiler.Compile(g, t); err == nil {
c.Run(g)
}
g.Close()
return nil
case "DESCRIPTION":
// DESCRIPTION is informational only
return nil
case "WHITESPACE_SENSITIVE":
// WHITESPACE_SENSITIVE is informational only (tells IDE/editors not to strip trailing whitespace)
return nil
case "CONFLICTS":
// CONFLICTS marks tests that shouldn't run in parallel; we run sequentially so this is a no-op
return nil
case "ARGS":
// Set command-line arguments for the test (CLI mode)
args := strings.Fields(strings.TrimSpace(b.String()))
p.p.Argv = append([]string{p.path}, args...)
p.cliMode = true
return nil
case "STDIN":
// Save stdin data to be fed to the script via custom stdin stream
p.stdinData = b.Bytes()
p.cliMode = true // STDIN implies CLI mode
return nil
case "FLAKY":
// FLAKY marks tests that may fail intermittently; treat as informational
return nil
case "CGI", "CAPTURE_STDIO":
// These require special execution modes we don't support yet
return skipError{reason: "unsupported section: " + part}
case "ENV":
// Set environment variables for the test
for _, line := range strings.Split(strings.TrimSpace(b.String()), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if pos := strings.IndexByte(line, '='); pos != -1 {
k := line[:pos]
v := line[pos+1:]
p.p.SetEnv(k, v)
}
}
return nil
case "COOKIE":
// Set cookies on the request
p.req.Header.Set("Cookie", strings.TrimRight(b.String(), "\r\n"))
return nil
case "POST_RAW":
// Raw POST data with Content-Type header on first line
data := b.String()
lines := strings.SplitN(data, "\n", 2)
if len(lines) == 2 {
// First line is Content-Type: ...
if strings.HasPrefix(lines[0], "Content-Type:") {
ct := strings.TrimSpace(strings.TrimPrefix(lines[0], "Content-Type:"))
body := strings.TrimRight(lines[1], "\r\n")
p.req = httptest.NewRequest("POST", "/"+path.Base(p.path), bytes.NewBufferString(body))
p.req.Header.Set("Content-Type", ct)
} else {
p.req = httptest.NewRequest("POST", "/"+path.Base(p.path), bytes.NewBuffer(bytes.TrimRight(b.Bytes(), "\r\n")))
p.req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
} else {
p.req = httptest.NewRequest("POST", "/"+path.Base(p.path), bytes.NewBuffer(bytes.TrimRight(b.Bytes(), "\r\n")))
p.req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
return nil
case "EXPECTHEADERS":
// EXPECTHEADERS checks HTTP response headers. In our test runner
// we don't validate response headers — just skip silently.
return nil
case "FILE_EXTERNAL":
// Read the script from an external file and delegate to FILE handler
extFile := strings.TrimSpace(b.String())
extPath := filepath.Join(filepath.Dir(p.path), extFile)
extData, err := os.ReadFile(extPath)
if err != nil {
return fmt.Errorf("FILE_EXTERNAL: cannot read %s: %s", extPath, err)
}
extBuf := bytes.NewBuffer(extData)
return p.handlePart("FILE", extBuf)
case "EXPECT_EXTERNAL", "EXPECTF_EXTERNAL", "EXPECTREGEX_EXTERNAL":
// Read the external file and delegate to the corresponding handler
extFile := strings.TrimSpace(b.String())
extPath := filepath.Join(filepath.Dir(p.path), extFile)
extData, err := os.ReadFile(extPath)
if err != nil {
return fmt.Errorf("file does not exist in %s:%d", p.path, 0)
}
extBuf := bytes.NewBuffer(extData)
basePart := strings.TrimSuffix(part, "_EXTERNAL")
return p.handlePart(basePart, extBuf)
default:
return fmt.Errorf("unhandled part type %s for test", part)
}
}
func runTest(t *testing.T, fpath string) (p *phptest, err error) {
p = &phptest{t: t, output: &limitedBuffer{}, name: fpath, path: fpath}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("failed to run: %s\n%s", r, debug.Stack())
}
}()
// read & parse test file
p.f, err = os.Open(fpath)
if err != nil {
return
}
defer p.f.Close()
p.reader = bufio.NewReader(p.f)
// prepare env
p.p = phpctx.NewProcess("test")
p.req = httptest.NewRequest("GET", "/"+path.Base(fpath), nil)
// Phase 1: Parse all sections into a map
sections := make(map[string]*bytes.Buffer)
var sectionOrder []string
var curBuf *bytes.Buffer
var curPart string
sectionRe := regexp.MustCompile("^--([A-Z_]+)--$")
for {
lin, err := p.reader.ReadString('\n')
if err != nil && err != io.EOF {
return p, err
}
atEOF := err == io.EOF
if atEOF && len(lin) == 0 {
break
}
if strings.HasPrefix(lin, "--") {
lin_trimmed := strings.TrimRight(lin, "\r\n")
if sub := sectionRe.FindSubmatch([]byte(lin_trimmed)); sub != nil {
thing := string(sub[1])
if curBuf != nil {
sections[curPart] = curBuf
sectionOrder = append(sectionOrder, curPart)
}
curBuf = &bytes.Buffer{}
curPart = thing
continue
}
}
if curBuf == nil {
return p, fmt.Errorf("malformed test file %s", fpath)
}
curBuf.Write([]byte(lin))
if atEOF {
break
}
}
if curBuf != nil {
sections[curPart] = curBuf
sectionOrder = append(sectionOrder, curPart)
}
// Phase 2: Process sections in dependency order.
// Sections that set up state (SKIPIF, INI, STDIN, etc.) must run before
// FILE/FILEEOF which executes the script, which must run before
// EXPECT/EXPECTF which checks output.
// Process in order: everything except FILE/FILEEOF/EXPECT*/CLEAN first,
// then FILE/FILEEOF, then EXPECT*/CLEAN.
var fileParts []string // FILE, FILEEOF
var expectParts []string // EXPECT, EXPECTF, EXPECTREGEX, CLEAN, etc.
var setupParts []string // everything else
for _, name := range sectionOrder {
switch name {
case "FILE", "FILEEOF", "FILE_EXTERNAL":
fileParts = append(fileParts, name)
case "EXPECT", "EXPECTF", "EXPECTREGEX", "EXPECT_EXTERNAL", "EXPECTF_EXTERNAL",
"EXPECTREGEX_EXTERNAL", "EXPECTHEADERS", "CLEAN":
expectParts = append(expectParts, name)
default:
setupParts = append(setupParts, name)
}
}
for _, name := range setupParts {
if err := p.handlePart(name, sections[name]); err != nil {
return p, err
}
}
for _, name := range fileParts {
if err := p.handlePart(name, sections[name]); err != nil {
return p, err
}
}
// Run CLEAN section unconditionally (even if EXPECT fails), to avoid leaving
// stale temp directories that break subsequent tests.
var expectErr error
for _, name := range expectParts {
if name == "CLEAN" {
// Always run CLEAN
p.handlePart(name, sections[name])
continue
}
if expectErr == nil {
if err := p.handlePart(name, sections[name]); err != nil {
expectErr = err
}
}
}
// If XFAIL is set and the test failed, convert to skip (expected failure)
if expectErr != nil && p.xfail != "" {
return p, skipError{reason: "XFAIL: " + p.xfail}
}
return p, expectErr
}
// expectfToRegex converts a PHP EXPECTF pattern to a Go regex.
// Format specifiers:
//
// %d - one or more digits
// %i - +/- followed by one or more digits
// %f - floating point number
// %c - single character
// %s - one or more non-newline characters
// %S - zero or more non-newline characters
// %a - one or more characters (including newlines)
// %A - zero or more characters (including newlines)
// %w - zero or more whitespace
// %x - one or more hex digits
// %e - directory separator
// %% - literal %
//
// sanitizeForRegex converts raw bytes to valid UTF-8 by treating each byte
// as a Latin-1 character (byte 0xNN -> rune U+00NN). This allows the Go
// regex engine to match binary data in PHP test output.
func sanitizeForRegex(s string) string {
var buf strings.Builder
for i := 0; i < len(s); i++ {
buf.WriteRune(rune(s[i]))
}
return buf.String()
}
func expectfToRegex(pattern string) string {
var result strings.Builder
i := 0
for i < len(pattern) {
if pattern[i] == '%' && i+1 < len(pattern) {
switch pattern[i+1] {
case 'd':
result.WriteString(`\d+`)
case 'i':
result.WriteString(`[+-]?\d+`)
case 'f':
result.WriteString(`[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?`)
case 'c':
result.WriteString(`.`)
case 's':
result.WriteString(`[^\r\n]+?`)
case 'S':
result.WriteString(`[^\r\n]*?`)
case 'a':
result.WriteString(`.+?`)
case 'A':
result.WriteString(`.*?`)
case 'w':
result.WriteString(`\s*`)
case 'x':
result.WriteString(`[0-9a-fA-F]+`)
case 'e':
result.WriteString(`[/\\]`)
case 'r':
// %r...%r embeds a raw regex between two %r markers
end := strings.Index(pattern[i+2:], "%r")
if end >= 0 {
result.WriteString(pattern[i+2 : i+2+end])
i += 2 + end + 2
continue
}
// No matching %r, treat as literal
result.WriteString(regexp.QuoteMeta(sanitizeForRegex(pattern[i : i+1])))
i++
continue
case '%':
result.WriteString(`%`)
case '0':
// %0 represents a NUL byte
result.WriteString(regexp.QuoteMeta(sanitizeForRegex("\x00")))
default:
result.WriteString(regexp.QuoteMeta(sanitizeForRegex(pattern[i : i+1])))
i++
continue
}
i += 2
} else {
// Decode full UTF-8 rune to avoid splitting multi-byte characters
_, size := utf8.DecodeRuneInString(pattern[i:])
result.WriteString(regexp.QuoteMeta(sanitizeForRegex(pattern[i : i+size])))
i += size
}
}
return result.String()
}
// testCache manages a file-based cache of test results so that known-passing
// tests can be skipped on subsequent runs. The cache file stores the path and
// modification time of each passing test. Set GORO_TEST_CACHE=1 to enable,
// GORO_TEST_CACHE_CLEAR=1 to reset the cache for a full regression check.
type testCache struct {
file string
entries map[string]time.Time // path -> file mod time when it last passed
}
const testCacheFile = "/tmp/goro_test_cache.json"
func loadTestCache() *testCache {
tc := &testCache{file: testCacheFile, entries: make(map[string]time.Time)}
data, err := os.ReadFile(testCacheFile)
if err != nil {
return tc
}
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
t, err := time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
continue
}
tc.entries[parts[1]] = t
}
return tc
}
func (tc *testCache) isCached(path string, info os.FileInfo) bool {
if cached, ok := tc.entries[path]; ok {
return info.ModTime().Equal(cached)
}
return false
}
func (tc *testCache) markPass(path string, info os.FileInfo) {
tc.entries[path] = info.ModTime()
}
func (tc *testCache) markFail(path string) {
delete(tc.entries, path)
}
func (tc *testCache) save() {
var buf strings.Builder
for path, modTime := range tc.entries {
fmt.Fprintf(&buf, "%s\t%s\n", modTime.Format(time.RFC3339Nano), path)
}
os.WriteFile(tc.file, []byte(buf.String()), 0644)
}
func TestPhp(t *testing.T) {
// Set TEST_PHP_EXECUTABLE so tests like bug54514 can compare with PHP_BINARY
if exe, err := os.Executable(); err == nil {
os.Setenv("TEST_PHP_EXECUTABLE", exe)
}
// Set RLIMIT_AS as a safety net. Go's virtual memory usage is much
// higher than actual RSS, so set this very high (64 GB) to avoid
// false triggers while still preventing 191 GB runaway allocations.
memLimit := uint64(8 * 1024 * 1024 * 1024) // 8 GB safety net per process
if v := os.Getenv("GORO_TEST_MEMLIMIT"); v != "" {
fmt.Sscanf(v, "%d", &memLimit)
}
setMemoryLimit(memLimit)
// Set Go's GC-aware soft limit if not already set via env
if os.Getenv("GOMEMLIMIT") == "" {
debug.SetMemoryLimit(3 * 1024 * 1024 * 1024) // 3 GB soft GC limit per process
}
// Known-hanging tests that cause OOM/infinite loops in the engine.
// These are skipped until the underlying bugs are fixed.
hangingTests := map[string]bool{
"test/php-8.5.5/ext/date/bug73460-002.phpt": true, // DateTime::sub DST infinite loop
"test/php-8.5.5/func_arg_fetch_optimization.phpt": true, // $x[][$y] recursion causes OOM before call depth limit
"test/php-8.5.5/ext/mbstring/utf_encodings.phpt": true, // Slow torture test (needs SKIP_SLOW_TESTS)
"test/php-8.5.5/ext/standard/file/file_get_contents_file_put_contents_5gb.phpt": true, // 5GB allocation, memory_limit=-1
"test/php-8.5.5/ext/standard/strings/gh15613.phpt": true, // memory_limit=-1, huge unpack
"test/php-8.5.5/ext/mbstring/euc_tw_encoding.phpt": true, // Slow mbstring encoding conversion test
"test/php-8.5.5/ext/mbstring/gb18030_encoding.phpt": true, // Slow mbstring encoding conversion test
"test/php-8.5.5/fibers/get-return-after-bailout.phpt": true, // Fiber + str_repeat(PHP_INT_MAX) hang
"test/php-8.5.5/fibers/backtrace-object.phpt": true, // Nil pointer in fiber backtrace
"test/php-8.5.5/fibers/backtrace-nested.phpt": true, // Nil pointer in fiber backtrace nested
"test/php-8.5.5/ext/standard/strings/gh15552.phpt": true, // sscanf huge arg index causes OOM
"test/php-8.5.5/ext/date/DateTimeImmutable_inherited_serialization.phpt": true, // Nil pointer in inherited serialization
"test/php-8.5.5/ext/date/DateTimePeriod_inherited_serialization.phpt": true, // Nil pointer in inherited serialization
"test/php-8.5.5/ext/date/DateTime_inherited_serialization.phpt": true, // Nil pointer in inherited serialization
"test/php-8.5.5/ext/date/bug60302-002.phpt": true, // Nil pointer in createFromFormat
"test/php-8.5.5/ext/date/bug65502.phpt": true, // Nil pointer in createFromFormat
"test/php-8.5.5/ext/date/bug68669.phpt": true, // Nil pointer in createFromFormat
"test/php-8.5.5/ext/date/bug72963.phpt": true, // Nil pointer in createFromFormat
"test/php-8.5.5/ext/date/bug80057.phpt": true, // Nil pointer in createFromFormat
"test/php-8.5.5/ext/date/gh10152.phpt": true, // Nil pointer in serialization
"test/php-8.5.5/ext/date/gh11455.phpt": true, // Nil pointer in carbon-style usage
"test/php-8.5.5/ext/standard/strings/sprintf_error.phpt": true, // Index out of range in sprintf
"test/php-8.5.5/ext/mbstring/cp850_encoding.phpt": true, // Exhaustive encoding test hangs
"test/php-8.5.5/ext/mbstring/cp866_encoding.phpt": true, // Exhaustive encoding test hangs
"test/php-8.5.5/ext/mbstring/cp932_encoding.phpt": true, // Exhaustive encoding test hangs
"test/php-8.5.5/ext/mbstring/cp936_encoding.phpt": true, // Exhaustive encoding test hangs
"test/php-8.5.5/ext/mbstring/cp950_encoding.phpt": true, // Exhaustive encoding test hangs
"test/php-8.5.5/ext/hash/hash_serialize_001.phpt": true, // Nil pointer in hash serialize
"test/php-8.5.5/ext/hash/xxhash_unserialize_memsize.phpt": true, // Nil pointer in hash unserialize
"test/php-8.5.5/ext/spl/iterator_021.phpt": true, // Nil pointer in RecursiveIteratorIterator
"test/php-8.5.5/ext/spl/iterator_022.phpt": true, // Nil pointer in RecursiveIteratorIterator
"test/php-8.5.5/ext/spl/iterator_033.phpt": true, // Nil pointer in RecursiveIteratorIterator
"test/php-8.5.5/ext/spl/iterator_034.phpt": true, // Nil pointer in RecursiveIteratorIterator
"test/php-8.5.5/ext/spl/iterator_040.phpt": true, // Nil pointer in RecursiveIteratorIterator
"test/php-8.5.5/ext/standard/file/bug38450.phpt": true, // Nil pointer in stream wrapper
"test/php-8.5.5/ext/standard/file/bug38450_1.phpt": true, // Nil pointer in stream wrapper
"test/php-8.5.5/ext/standard/file/userstreams_006.phpt": true, // Nil pointer in user streams
"test/php-8.5.5/ext/standard/streams/bug60455_02.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/bug60455_03.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/bug60817.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/bug67626.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/bug78662.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/gh14506.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/gh15908.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/set_file_buffer.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/stream_get_line_NUL_delimiter.phpt": true, // Nil pointer in stream
"test/php-8.5.5/ext/standard/streams/stream_set_chunk_size.phpt": true, // Nil pointer in stream
"test/php-8.5.5/offsets/appending_containers_in_fetch.phpt": true, // Nil pointer in offset
"test/php-8.5.5/type_declarations/typed_properties_093.phpt": true, // Interface conversion panic
"test/php-8.5.5/fibers/destructors_002.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_003.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_004.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_005.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_006.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_007.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_008.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_009.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_010.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/fibers/destructors_011.phpt": true, // Stack overflow in fiber destructor
"test/php-8.5.5/gh13569.phpt": true, // Stack overflow with 30k WeakMap entries
"test/php-8.5.5/gh13670_001.phpt": true, // Stack overflow with GC cycle destructors