main shinobi / tests / runner / case.go
  1package main
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"io/fs"
  9	"os"
 10	"os/exec"
 11	"path/filepath"
 12	"regexp"
 13	"sort"
 14	"strings"
 15	"syscall"
 16	"time"
 17)
 18
 19func runcase(cfg suiteconfig, tc testcase) caseresult {
 20	result := caseresult{Test: tc}
 21
 22	workdir := filepath.Join(cfg.temproot, filepath.FromSlash(tc.RelPath))
 23	if err := os.MkdirAll(workdir, 0o755); err != nil {
 24		return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("create workdir: %v", err))
 25	}
 26
 27	if err := materializesetup(workdir, tc.Meta.Setup); err != nil {
 28		return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("prepare setup: %v", err))
 29	}
 30
 31	execdir := workdir
 32	if tc.Meta.Cwd != "" {
 33		execdir = filepath.Join(workdir, filepath.FromSlash(tc.Meta.Cwd))
 34		if err := os.MkdirAll(execdir, 0o755); err != nil {
 35			return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("prepare cwd: %v", err))
 36		}
 37	}
 38
 39	makename, aliases := mkfiles(tc)
 40	for _, alias := range aliases {
 41		aliaspath := filepath.Join(workdir, filepath.FromSlash(alias))
 42		if err := os.MkdirAll(filepath.Dir(aliaspath), 0o755); err != nil {
 43			return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("prepare makefile dir: %v", err))
 44		}
 45		if err := os.WriteFile(aliaspath, []byte(tc.MakeContents), 0o644); err != nil {
 46			return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("write makefile: %v", err))
 47		}
 48	}
 49
 50	logpath := filepath.Join(workdir, tc.Meta.Case+".log")
 51	runpath := filepath.Join(workdir, tc.Meta.Case+".run")
 52	basepath := filepath.Join(workdir, tc.Meta.Case+".base")
 53	diffpath := filepath.Join(workdir, tc.Meta.Case+".diff")
 54
 55	command, commandstring, err := buildcommand(cfg.makepath, makename, tc)
 56	if err != nil {
 57		return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("build command: %v", err))
 58	}
 59
 60	env := buildenv(cfg, execdir, tc.Meta.Env)
 61	logdata, exitstatus, timedout, err := executecase(command, execdir, env, tc.Meta.Stdin, tc.Meta.TimeoutSeconds)
 62	if err != nil {
 63		return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("run case: %v", err))
 64	}
 65	if timedout {
 66		logdata = append(logdata, []byte(fmt.Sprintf("\ntest timed out after %d seconds\n", tc.Meta.TimeoutSeconds))...)
 67	}
 68
 69	extratmp, extratext, err := leftovertempfiles(filepath.Join(workdir, "_tmp"))
 70	if err != nil {
 71		return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("check temp files: %v", err))
 72	}
 73	if extratmp {
 74		logdata = append(logdata, []byte(extratext)...)
 75	}
 76
 77	if err := os.WriteFile(logpath, logdata, 0o644); err != nil {
 78		return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("write log: %v", err))
 79	}
 80
 81	result.ExitMatched = exitstatus == tc.Meta.ExpectedExit
 82	result.OutputMatched = compareoutput(tc, cfg.makepath, workdir, env, string(logdata))
 83	result.ExtraTmp = extratmp
 84	result.Passed = result.ExitMatched && result.OutputMatched && !result.ExtraTmp
 85
 86	if result.Passed {
 87		if cfg.keep {
 88			_, preserveerr := preservecasedir(cfg, tc.RelPath, workdir)
 89			if preserveerr != nil {
 90				return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("preserve case: %v", preserveerr))
 91			}
 92		}
 93		return result
 94	}
 95
 96	if err := os.WriteFile(runpath, []byte(commandstring), 0o644); err != nil {
 97		return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("write run file: %v", err))
 98	}
 99	if tc.Meta.CompareOutput && !result.OutputMatched {
100		if err := os.WriteFile(basepath, []byte(tc.ExpectedOut), 0o644); err != nil {
101			return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("write base file: %v", err))
102		}
103		if err := writediff(basepath, logpath, diffpath); err != nil {
104			return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("write diff: %v", err))
105		}
106	}
107
108	_, err = preservecasedir(cfg, tc.RelPath, workdir)
109	if err != nil {
110		return failedcaseresult(cfg, tc, result, workdir, fmt.Sprintf("preserve failure: %v", err))
111	}
112	return result
113}
114
115func failedcaseresult(cfg suiteconfig, tc testcase, result caseresult, workdir, message string) caseresult {
116	errorfile := filepath.Join(workdir, "runner-error.txt")
117	if workdir != "" {
118		_ = os.MkdirAll(workdir, 0o755)
119		_ = os.WriteFile(errorfile, []byte(message+"\n"), 0o644)
120		_, _ = preservecasedir(cfg, tc.RelPath, workdir)
121	}
122	return result
123}
124
125func materializesetup(root string, entries []setupentry) error {
126	for _, entry := range entries {
127		fullpath := filepath.Join(root, filepath.FromSlash(entry.Path))
128		switch entry.Kind {
129		case "dir":
130			if err := os.MkdirAll(fullpath, 0o755); err != nil {
131				return err
132			}
133			if mode, ok := parsemode(entry.Mode); ok {
134				if err := os.Chmod(fullpath, mode); err != nil {
135					return err
136				}
137			}
138			if err := settimes(fullpath, entry.MTime); err != nil {
139				return err
140			}
141		case "file":
142			if err := os.MkdirAll(filepath.Dir(fullpath), 0o755); err != nil {
143				return err
144			}
145			mode := fs.FileMode(0o644)
146			if parsed, ok := parsemode(entry.Mode); ok {
147				mode = parsed
148			}
149			if err := os.WriteFile(fullpath, []byte(entry.Content), mode); err != nil {
150				return err
151			}
152			if err := settimes(fullpath, entry.MTime); err != nil {
153				return err
154			}
155		case "symlink":
156			if err := os.MkdirAll(filepath.Dir(fullpath), 0o755); err != nil {
157				return err
158			}
159			if err := os.Symlink(entry.Target, fullpath); err != nil {
160				return err
161			}
162		default:
163			return fmt.Errorf("unsupported setup entry kind %q", entry.Kind)
164		}
165	}
166	return nil
167}
168
169func parsemode(s string) (fs.FileMode, bool) {
170	if s == "" {
171		return 0, false
172	}
173	var mode uint32
174	_, err := fmt.Sscanf(s, "%o", &mode)
175	if err != nil {
176		return 0, false
177	}
178	return fs.FileMode(mode), true
179}
180
181func settimes(path string, mtime int64) error {
182	if mtime == 0 {
183		return nil
184	}
185	ts := time.Unix(mtime, 0)
186	return os.Chtimes(path, ts, ts)
187}
188
189func mkfiles(tc testcase) (string, []string) {
190	if tc.Meta.Command != "" {
191		return "", nil
192	}
193	seen := map[string]bool{}
194	var names []string
195	add := func(name string) {
196		if name == "" || seen[name] {
197			return
198		}
199		seen[name] = true
200		names = append(names, name)
201	}
202
203	add(tc.Meta.MakefileName)
204
205	for _, match := range makefilenamepattern.FindAllString(tc.MakeContents, -1) {
206		add(match)
207	}
208	for _, match := range makefilenamepattern.FindAllString(tc.ExpectedOut, -1) {
209		add(match)
210	}
211	for _, entry := range tc.Meta.Setup {
212		for _, match := range makefilenamepattern.FindAllString(entry.Path, -1) {
213			add(match)
214		}
215		for _, match := range makefilenamepattern.FindAllString(entry.Content, -1) {
216			add(match)
217		}
218	}
219	if tc.Meta.Options.Shell != "" {
220		for _, match := range makefilenamepattern.FindAllString(tc.Meta.Options.Shell, -1) {
221			add(match)
222		}
223	}
224	for _, arg := range tc.Meta.Options.Args {
225		for _, match := range makefilenamepattern.FindAllString(arg, -1) {
226			add(match)
227		}
228	}
229
230	primary := ""
231	if tc.MakeContents != "" {
232		defaultname := tc.Meta.Case + ".mk"
233		add(defaultname)
234		primary = defaultname
235	}
236	if len(names) > 0 {
237		primary = names[0]
238	}
239	return primary, names
240}
241
242func buildcommand(makepath, makefilename string, tc testcase) (*exec.Cmd, string, error) {
243	if tc.Meta.Command != "" {
244		cmd := exec.Command("/bin/sh", "-c", tc.Meta.Command)
245		return cmd, formatruncommand([]string{tc.Meta.Command}), nil
246	}
247
248	quotedmake := shellquote(makepath)
249
250	switch tc.Meta.OptionsMode {
251	case "", "argv":
252		args := []string{makepath}
253		if makefilename != "" {
254			args = append(args, "-f", makefilename)
255		}
256		if tc.Meta.Options.Shell != "" && strings.TrimSpace(tc.Meta.Options.Shell) != "" {
257			args = append(args, tc.Meta.Options.Shell)
258		}
259		args = append(args, tc.Meta.Options.Args...)
260		cmd := exec.Command(args[0], args[1:]...)
261		return cmd, formatruncommand(args), nil
262	case "shell":
263		shellcommand := quotedmake
264		if makefilename != "" {
265			shellcommand += " -f " + shellquote(makefilename)
266		}
267		if tc.Meta.Options.Shell != "" {
268			shellcommand += " " + tc.Meta.Options.Shell
269		}
270		cmd := exec.Command("/bin/sh", "-c", shellcommand)
271		return cmd, formatruncommand([]string{shellcommand}), nil
272	default:
273		return nil, "", fmt.Errorf("unsupported options_mode %q", tc.Meta.OptionsMode)
274	}
275}
276
277func formatruncommand(args []string) string {
278	if len(args) == 1 {
279		return args[0] + "\n"
280	}
281	quoted := make([]string, 0, len(args))
282	for _, arg := range args {
283		quoted = append(quoted, shellquote(arg))
284	}
285	return strings.Join(quoted, " ") + "\n"
286}
287
288func shellquote(s string) string {
289	if s == "" {
290		return "''"
291	}
292	if !strings.ContainsAny(s, " \t\n'\"#$&;|*?()<>[]{}!~`") {
293		return s
294	}
295	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
296}
297
298// had some issues, so create a stable env and prepends tests/ to path so
299// we always get our make and testhelp wrappers
300func buildenv(cfg suiteconfig, workdir string, deltas map[string]*string) []string {
301	envmap := make(map[string]string)
302	for _, entry := range os.Environ() {
303		parts := strings.SplitN(entry, "=", 2)
304		if len(parts) != 2 {
305			continue
306		}
307		envmap[parts[0]] = parts[1]
308	}
309	envmap["LC_ALL"] = "C"
310	envmap["LANG"] = "C"
311	envmap["LANGUAGE"] = "C"
312	tmpdir := filepath.Join(workdir, "_tmp")
313	envmap["TMPDIR"] = tmpdir
314	envmap["TMP"] = tmpdir
315	envmap["TEMP"] = tmpdir
316	path := filepath.Join(cfg.reporoot, "tests")
317	if current, ok := envmap["PATH"]; ok && current != "" {
318		path += string(os.PathListSeparator) + current
319	}
320	envmap["PATH"] = path
321	for key, value := range deltas {
322		if value == nil {
323			delete(envmap, key)
324			continue
325		}
326		envmap[key] = expandenvplaceholders(*value, workdir, envmap)
327	}
328	keys := make([]string, 0, len(envmap))
329	for key := range envmap {
330		keys = append(keys, key)
331	}
332	sort.Strings(keys)
333	env := make([]string, 0, len(keys))
334	for _, key := range keys {
335		env = append(env, key+"="+envmap[key])
336	}
337	return env
338}
339
340func expandenvplaceholders(value, workdir string, envmap map[string]string) string {
341	value = strings.ReplaceAll(value, "#PWD#", filepath.ToSlash(workdir))
342	value = strings.ReplaceAll(value, "#PATH#", envmap["PATH"])
343	return value
344}
345
346func executecase(cmd *exec.Cmd, workdir string, env []string, stdin string, timeoutseconds int) ([]byte, int, bool, error) {
347	if err := os.MkdirAll(filepath.Join(workdir, "_tmp"), 0o755); err != nil {
348		return nil, 0, false, err
349	}
350
351	ctx := context.Background()
352	var cancel context.CancelFunc
353	if timeoutseconds > 0 {
354		ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutseconds)*time.Second)
355		defer cancel()
356	}
357
358	wrapped := exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
359	wrapped.Dir = workdir
360	wrapped.Env = env
361	var combined bytes.Buffer
362	wrapped.Stderr = &combined
363	wrapped.Stdout = &combined
364	if stdin != "" {
365		wrapped.Stdin = strings.NewReader(stdin)
366	}
367
368	err := wrapped.Run()
369	output := combined.Bytes()
370
371	timedout := errors.Is(ctx.Err(), context.DeadlineExceeded)
372	if timedout {
373		return output, 14, true, nil
374	}
375
376	if err == nil {
377		return output, 0, false, nil
378	}
379
380	var exiterr *exec.ExitError
381	if errors.As(err, &exiterr) {
382		if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
383			return output, int(status), false, nil
384		}
385		return output, exiterr.ExitCode() << 8, false, nil
386	}
387
388	return output, 0, false, err
389}
390
391func leftovertempfiles(tmproot string) (bool, string, error) {
392	entries, err := os.ReadDir(tmproot)
393	if err != nil {
394		if errors.Is(err, os.ErrNotExist) {
395			return false, "", nil
396		}
397		return false, "", err
398	}
399	if len(entries) == 0 {
400		return false, "", nil
401	}
402	names := make([]string, 0, len(entries))
403	for _, entry := range entries {
404		names = append(names, filepath.ToSlash(filepath.Join("_tmp", entry.Name())))
405	}
406	sort.Strings(names)
407	return true, "Leftover temporary files: " + strings.Join(names, " ") + "\n", nil
408}
409
410// we expand #PWD# because the gnu make cases use that
411// placeholder from the old perl suite runner for per run work directories
412// also strips clock skew issues, normalizes newlines, and optionally applies regex
413// matching for some cases using that (the gnu suite, mostly in makeflags and some
414// options tests).
415func compareoutput(tc testcase, makepath, workdir string, env []string, actual string) bool {
416	if !tc.Meta.CompareOutput || tc.Meta.OutputMode == "ignore" {
417		return true
418	}
419
420	expected := expandexpectedoutput(tc.ExpectedOut, makepath, workdir, envpath(env))
421	filteredactual := stripskewlines(actual)
422	if filteredactual == expected {
423		return true
424	}
425
426	normalizedexpected := normalizenewlines(expected)
427	normalizedactual := normalizenewlines(filteredactual)
428	if normalizedactual == normalizedexpected {
429		return true
430	}
431
432	if tc.Meta.OutputMode != "regex" {
433		return false
434	}
435
436	patterntext := strings.TrimSuffix(normalizedexpected, "\n")
437	if len(patterntext) < 2 || patterntext[0] != '/' || patterntext[len(patterntext)-1] != '/' {
438		return false
439	}
440	pattern := patterntext[1 : len(patterntext)-1]
441	re, err := regexp.Compile(pattern)
442	if err != nil {
443		return false
444	}
445	return re.MatchString(actual) || re.MatchString(normalizedactual)
446}
447
448func expandexpectedoutput(expected, makepath, workdir, path string) string {
449	expected = strings.ReplaceAll(expected, "#MAKE#", filepath.ToSlash(makepath))
450	expected = strings.ReplaceAll(expected, "#PWD#", filepath.ToSlash(workdir))
451	expected = strings.ReplaceAll(expected, "#PATH#", path)
452	return expected
453}
454
455func envpath(env []string) string {
456	for _, entry := range env {
457		if strings.HasPrefix(entry, "PATH=") {
458			return strings.TrimPrefix(entry, "PATH=")
459		}
460	}
461	return ""
462}
463
464func stripskewlines(s string) string {
465	var kept []string
466	for _, line := range strings.SplitAfter(s, "\n") {
467		lower := strings.ToLower(line)
468		if strings.Contains(lower, "modification time") && strings.Contains(lower, "in the future") {
469			continue
470		}
471		if strings.Contains(lower, "clock skew detected") {
472			continue
473		}
474		kept = append(kept, line)
475	}
476	return strings.Join(kept, "")
477}
478
479func normalizenewlines(s string) string {
480	return strings.ReplaceAll(s, "\r\n", "\n")
481}
482
483func writediff(basepath, logpath, diffpath string) error {
484	diffbin, err := exec.LookPath("diff")
485	if err != nil {
486		message := fmt.Sprintf("Log file %s differs from base file %s\n", logpath, basepath)
487		return os.WriteFile(diffpath, []byte(message), 0o644)
488	}
489
490	cmd := exec.Command(diffbin, "-u", basepath, logpath)
491	output, err := cmd.CombinedOutput()
492	if err != nil {
493		var exiterr *exec.ExitError
494		if !errors.As(err, &exiterr) {
495			return err
496		}
497	}
498	return os.WriteFile(diffpath, output, 0o644)
499}