package container import ( "archive/tar" "bufio" "bytes" "context" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "errors" "github.com/go-git/go-billy/v5/helper/polyfill" "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5/plumbing/format/gitignore" "github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/lookpath" "golang.org/x/term" ) type HostEnvironment struct { Path string TmpDir string ToolCache string Workdir string ActPath string CleanUp func() StdOut io.Writer } func (e *HostEnvironment) Create(capAdd []string, capDrop []string) common.Executor { return func(ctx context.Context) error { return nil } } func (e *HostEnvironment) Close() common.Executor { return func(ctx context.Context) error { return nil } } func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor { return func(ctx context.Context) error { for _, f := range files { if err := os.MkdirAll(filepath.Dir(filepath.Join(destPath, f.Name)), 0777); err != nil { return err } if err := os.WriteFile(filepath.Join(destPath, f.Name), []byte(f.Body), fs.FileMode(f.Mode)); err != nil { return err } } return nil } } func (e *HostEnvironment) CopyDir(destPath string, srcPath string, useGitIgnore bool) common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) srcPrefix := filepath.Dir(srcPath) if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) { srcPrefix += string(filepath.Separator) } logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath) var ignorer gitignore.Matcher if useGitIgnore { ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil) if err != nil { logger.Debugf("Error loading .gitignore: %v", err) } ignorer = gitignore.NewMatcher(ps) } fc := &fileCollector{ Fs: &defaultFs{}, Ignorer: ignorer, SrcPath: srcPath, SrcPrefix: srcPrefix, Handler: ©Collector{ DstDir: destPath, }, } return filepath.Walk(srcPath, fc.collectFiles(ctx, []string{})) } } func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) { buf := &bytes.Buffer{} tw := tar.NewWriter(buf) defer tw.Close() srcPath = filepath.Clean(srcPath) fi, err := os.Lstat(srcPath) if err != nil { return nil, err } tc := &tarCollector{ TarWriter: tw, } if fi.IsDir() { srcPrefix := filepath.Dir(srcPath) if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) { srcPrefix += string(filepath.Separator) } fc := &fileCollector{ Fs: &defaultFs{}, SrcPath: srcPath, SrcPrefix: srcPrefix, Handler: tc, } err = filepath.Walk(srcPath, fc.collectFiles(ctx, []string{})) if err != nil { return nil, err } } else { var f io.ReadCloser var linkname string if fi.Mode()&fs.ModeSymlink != 0 { linkname, err = os.Readlink(srcPath) if err != nil { return nil, err } } else { f, err = os.Open(srcPath) if err != nil { return nil, err } defer f.Close() } err := tc.WriteFile(fi.Name(), fi, linkname, f) if err != nil { return nil, err } } return io.NopCloser(buf), nil } func (e *HostEnvironment) Pull(forcePull bool) common.Executor { return func(ctx context.Context) error { return nil } } func (e *HostEnvironment) Start(attach bool) common.Executor { return func(ctx context.Context) error { return nil } } type ptyWriter struct { Out io.Writer AutoStop bool dirtyLine bool } func (w *ptyWriter) Write(buf []byte) (int, error) { if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 { n, err := w.Out.Write(buf[:len(buf)-1]) if err != nil { return n, err } if w.dirtyLine || len(buf) > 1 && buf[len(buf)-2] != '\n' { _, _ = w.Out.Write([]byte("\n")) return n, io.EOF } return n, io.EOF } w.dirtyLine = strings.LastIndex(string(buf), "\n") < len(buf)-1 return w.Out.Write(buf) } type localEnv struct { env map[string]string } func (l *localEnv) Getenv(name string) string { if runtime.GOOS == "windows" { for k, v := range l.env { if strings.EqualFold(name, k) { return v } } return "" } return l.env[name] } func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) { f, err := lookpath.LookPath2(cmd, &localEnv{env: env}) if err != nil { err := "Cannot find: " + fmt.Sprint(cmd) + " in PATH" if _, _err := writer.Write([]byte(err + "\n")); _err != nil { return "", fmt.Errorf("%v: %w", err, _err) } return "", errors.New(err) } return f, nil } func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) { ppty, tty, err := openPty() if err != nil { return nil, nil, err } if term.IsTerminal(int(tty.Fd())) { _, err := term.MakeRaw(int(tty.Fd())) if err != nil { ppty.Close() tty.Close() return nil, nil, err } } cmd.Stdin = tty cmd.Stdout = tty cmd.Stderr = tty cmd.SysProcAttr = getSysProcAttr(cmdline, true) return ppty, tty, nil } func writeKeepAlive(ppty io.Writer) { c := 1 var err error for c == 1 && err == nil { c, err = ppty.Write([]byte{4}) <-time.After(time.Second) } } func copyPtyOutput(writer io.Writer, ppty io.Reader, finishLog context.CancelFunc) { defer func() { finishLog() }() if _, err := io.Copy(writer, ppty); err != nil { return } } func (e *HostEnvironment) UpdateFromImageEnv(env *map[string]string) common.Executor { return func(ctx context.Context) error { return nil } } func getEnvListFromMap(env map[string]string) []string { envList := make([]string, 0) for k, v := range env { envList = append(envList, fmt.Sprintf("%s=%s", k, v)) } return envList } func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline string, env map[string]string, user, workdir string) error { envList := getEnvListFromMap(env) var wd string if workdir != "" { if filepath.IsAbs(workdir) { wd = workdir } else { wd = filepath.Join(e.Path, workdir) } } else { wd = e.Path } f, err := lookupPathHost(command[0], env, e.StdOut) if err != nil { return err } cmd := exec.CommandContext(ctx, f) cmd.Path = f cmd.Args = command cmd.Stdin = nil cmd.Stdout = e.StdOut cmd.Env = envList cmd.Stderr = e.StdOut cmd.Dir = wd cmd.SysProcAttr = getSysProcAttr(cmdline, false) var ppty *os.File var tty *os.File defer func() { if ppty != nil { ppty.Close() } if tty != nil { tty.Close() } }() if true /* allocate Terminal */ { var err error ppty, tty, err = setupPty(cmd, cmdline) if err != nil { common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error()) } } writer := &ptyWriter{Out: e.StdOut} logctx, finishLog := context.WithCancel(context.Background()) if ppty != nil { go copyPtyOutput(writer, ppty, finishLog) } else { finishLog() } if ppty != nil { go writeKeepAlive(ppty) } err = cmd.Run() if err != nil { return err } if tty != nil { writer.AutoStop = true if _, err := tty.Write([]byte("\x04")); err != nil { common.Logger(ctx).Debug("Failed to write EOT") } } <-logctx.Done() if ppty != nil { ppty.Close() ppty = nil } return err } func (e *HostEnvironment) Exec(command []string /*cmdline string, */, env map[string]string, user, workdir string) common.Executor { return func(ctx context.Context) error { if err := e.exec(ctx, command, "" /*cmdline*/, env, user, workdir); err != nil { select { case <-ctx.Done(): return fmt.Errorf("this step has been cancelled: %w", err) default: return err } } return nil } } func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { localEnv := *env return func(ctx context.Context) error { envTar, err := e.GetContainerArchive(ctx, srcPath) if err != nil { return nil } defer envTar.Close() reader := tar.NewReader(envTar) _, err = reader.Next() if err != nil && err != io.EOF { return err } s := bufio.NewScanner(reader) for s.Scan() { line := s.Text() singleLineEnv := strings.Index(line, "=") multiLineEnv := strings.Index(line, "<<") if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) { localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:] } else if multiLineEnv != -1 { multiLineEnvContent := "" multiLineEnvDelimiter := line[multiLineEnv+2:] delimiterFound := false for s.Scan() { content := s.Text() if content == multiLineEnvDelimiter { delimiterFound = true break } if multiLineEnvContent != "" { multiLineEnvContent += "\n" } multiLineEnvContent += content } if !delimiterFound { return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter) } localEnv[line[:multiLineEnv]] = multiLineEnvContent } else { return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line) } } env = &localEnv return nil } } func (e *HostEnvironment) UpdateFromPath(env *map[string]string) common.Executor { localEnv := *env return func(ctx context.Context) error { pathTar, err := e.GetContainerArchive(ctx, localEnv["GITHUB_PATH"]) if err != nil { return err } defer pathTar.Close() reader := tar.NewReader(pathTar) _, err = reader.Next() if err != nil && err != io.EOF { return err } s := bufio.NewScanner(reader) for s.Scan() { line := s.Text() pathSep := string(filepath.ListSeparator) localEnv[e.GetPathVariableName()] = fmt.Sprintf("%s%s%s", line, pathSep, localEnv[e.GetPathVariableName()]) } env = &localEnv return nil } } func (e *HostEnvironment) Remove() common.Executor { return func(ctx context.Context) error { if e.CleanUp != nil { e.CleanUp() } return os.RemoveAll(e.Path) } } func (e *HostEnvironment) ToContainerPath(path string) string { if bp, err := filepath.Rel(e.Workdir, path); err != nil { return filepath.Join(e.Path, bp) } else if filepath.Clean(e.Workdir) == filepath.Clean(path) { return e.Path } return path } func (e *HostEnvironment) GetActPath() string { return e.ActPath } func (*HostEnvironment) GetPathVariableName() string { if runtime.GOOS == "plan9" { return "path" } else if runtime.GOOS == "windows" { return "Path" // Actually we need a case insensitive map } return "PATH" } func (e *HostEnvironment) DefaultPathVariable() string { v, _ := os.LookupEnv(e.GetPathVariableName()) return v } func (*HostEnvironment) JoinPathVariable(paths ...string) string { return strings.Join(paths, string(filepath.ListSeparator)) } func (e *HostEnvironment) GetRunnerContext(ctx context.Context) map[string]interface{} { return map[string]interface{}{ "os": runtime.GOOS, "arch": runtime.GOARCH, "temp": e.TmpDir, "tool_cache": e.ToolCache, } } func (e *HostEnvironment) ReplaceLogWriter(stdout io.Writer, stderr io.Writer) (io.Writer, io.Writer) { org := e.StdOut e.StdOut = stdout return org, org }