package container import ( "archive/tar" "bytes" "context" "errors" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "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" "golang.org/x/term" "github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/lookpath" ) type HostEnvironment struct { Path string TmpDir string ToolCache string Workdir string ActPath string CleanUp func() StdOut io.Writer } func (e *HostEnvironment) Create(_ []string, _ []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)), 0o777); 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) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error { if err := os.RemoveAll(destPath); err != nil { return err } tr := tar.NewReader(tarStream) cp := ©Collector{ DstDir: destPath, } for { ti, err := tr.Next() if errors.Is(err, io.EOF) { return nil } else if err != nil { return err } if ti.FileInfo().IsDir() { continue } if ctx.Err() != nil { return fmt.Errorf("CopyTarStream has been cancelled") } if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil { return err } } } 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(_ bool) common.Executor { return func(ctx context.Context) error { return nil } } func (e *HostEnvironment) Start(_ 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(_ *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, _, 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 e.ExecWithCmdLine(command, "", env, user, workdir) } func (e *HostEnvironment) ExecWithCmdLine(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 { return parseEnvFile(e, srcPath, env) } 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 { actPath := e.ActPath if runtime.GOOS == "windows" { actPath = strings.ReplaceAll(actPath, "\\", "/") } return 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)) } // Reference for Arch values for runner.arch // https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context func goArchToActionArch(arch string) string { archMapper := map[string]string{ "x86_64": "X64", "386": "X86", "aarch64": "ARM64", } if arch, ok := archMapper[arch]; ok { return arch } return arch } func goOsToActionOs(os string) string { osMapper := map[string]string{ "darwin": "macOS", } if os, ok := osMapper[os]; ok { return os } return os } func (e *HostEnvironment) GetRunnerContext(_ context.Context) map[string]interface{} { return map[string]interface{}{ "os": goOsToActionOs(runtime.GOOS), "arch": goArchToActionArch(runtime.GOARCH), "temp": e.TmpDir, "tool_cache": e.ToolCache, } } func (e *HostEnvironment) ReplaceLogWriter(stdout io.Writer, _ io.Writer) (io.Writer, io.Writer) { org := e.StdOut e.StdOut = stdout return org, org } func (*HostEnvironment) IsEnvironmentCaseInsensitive() bool { return runtime.GOOS == "windows" }