blob: 36298a800666e9bf747f12b8e831bf5642600070 [file] [log] [blame]
// Copyright 2023 The Shac Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package engine
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"go.fuchsia.dev/shac-project/shac/internal/sandbox"
"go.starlark.net/starlark"
)
// ctxOsExec implements the native function ctx.os.exec().
//
// Make sure to update //doc/stdlib.star whenever this function is modified.
func ctxOsExec(ctx context.Context, s *shacState, name string, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var argcmd starlark.Sequence
var argcwd starlark.String
var argenv = starlark.NewDict(0)
var argraiseOnFailure starlark.Bool = true
var argallowNetwork starlark.Bool
if err := starlark.UnpackArgs(name, args, kwargs,
"cmd", &argcmd,
"cwd?", &argcwd,
"env?", &argenv,
"raise_on_failure?", &argraiseOnFailure,
"allow_network?", &argallowNetwork,
); err != nil {
return nil, err
}
if argcmd.Len() == 0 {
return nil, errors.New("cmdline must not be an empty list")
}
env := map[string]string{}
for _, item := range argenv.Items() {
k, ok := item[0].(starlark.String)
if !ok {
return nil, fmt.Errorf("\"env\" key is not a string: %s", item[0])
}
v, ok := item[1].(starlark.String)
if !ok {
return nil, fmt.Errorf("\"env\" value is not a string: %s", item[1])
}
env[string(k)] = string(v)
}
cwd := filepath.Join(s.root, s.subdir)
if s := string(argcwd); s != "" {
var err error
cwd, err = absPath(s, cwd)
if err != nil {
return nil, err
}
}
fullCmd := sequenceToStrings(argcmd)
if fullCmd == nil {
return nil, fmt.Errorf("for parameter \"cmd\": got %s, want sequence of str", argcmd.Type())
}
exeParts := strings.Split(fullCmd[0], string(os.PathSeparator))
if exeParts[0] == "." {
// exec.Command doesn't evaluate ".", so convert to an absolute path.
exeParts[0] = s.root
fullCmd[0] = strings.Join(exeParts, string(os.PathSeparator))
} else {
// nsjail doesn't do $PATH-based resolution of the command it's given, so it
// must either be an absolute or relative path. Do this resolution
// unconditionally for consistency across platforms even though it's not
// necessary when not using nsjail.
var err error
fullCmd[0], err = exec.LookPath(fullCmd[0])
if err != nil && !errors.Is(err, exec.ErrDot) {
return nil, err
}
}
tempDir, err := s.newTempDir()
if err != nil {
return nil, err
}
// TODO(olivernewman): Catch errors.
defer os.RemoveAll(tempDir)
config := &sandbox.Config{
Cmd: fullCmd,
Cwd: cwd,
AllowNetwork: bool(argallowNetwork),
TempDir: tempDir,
Env: env,
}
if runtime.GOOS == "windows" {
// config.Mounts is ignored for the moment on Windows.
// TODO(olivernewman): Add an env_prefixes argument to exec() so $PATH can
// be controlled without completely overriding it.
config.Env["PATH"] = strings.Join([]string{
filepath.Join(runtime.GOROOT(), "bin"),
}, string(os.PathListSeparator))
} else {
config.Mounts = []sandbox.Mount{
// TODO(olivernewman): Mount the checkout read-only by default.
{Path: s.root, Writeable: true},
// System binaries.
{Path: "/bin"},
// OS-provided utilities.
{Path: "/dev/null", Writeable: true},
{Path: "/dev/urandom"},
{Path: "/dev/zero"},
// DNS configs.
{Path: "/etc/nsswitch.conf"},
{Path: "/etc/resolv.conf"},
// Required for https.
{Path: "/etc/ssl/certs"},
// These are required for bash to work.
{Path: "/lib"},
{Path: "/lib64"},
// More system binaries.
{Path: "/usr/bin"},
// OS header files.
{Path: "/usr/include"},
// System compilers.
{Path: "/usr/lib"},
}
// TODO(olivernewman): Add an env_prefixes argument to exec() so $PATH can
// be controlled without completely overriding it.
config.Env["PATH"] = strings.Join([]string{
"/usr/bin",
"/bin",
// TODO(olivernewman): Use a hermetic Go installation, don't add $GOROOT
// to $PATH.
filepath.Join(runtime.GOROOT(), "bin"),
}, string(os.PathListSeparator))
}
// Mount $GOROOT unless it's a subdirectory of the checkout dir, in
// which case it will already be mounted.
// TODO(olivernewman): This is necessary because checks for shac itself
// assume Go is pre-installed. Switch to a hermetic Go installation that
// installs Go in the checkout directory, and stop explicitly mounting
// $GOROOT.
if !strings.HasPrefix(runtime.GOROOT(), s.root+string(os.PathSeparator)) {
config.Mounts = append(config.Mounts, sandbox.Mount{Path: runtime.GOROOT()})
}
cmd := s.sandbox.Command(ctx, config)
stdout := buffers.get()
stderr := buffers.get()
defer func() {
buffers.push(stdout)
buffers.push(stderr)
}()
// TODO(olivernewman): Also handle commands that may output non-utf-8 bytes.
cmd.Stdout = stdout
cmd.Stderr = stderr
var retcode int
// Serialize start given the issue described at sandbox.Mu.
sandbox.Mu.RLock()
err = cmd.Start()
sandbox.Mu.RUnlock()
if err != nil {
return nil, err
}
if err = cmd.Wait(); err != nil {
var errExit *exec.ExitError
if errors.As(err, &errExit) {
if argraiseOnFailure {
var msgBuilder strings.Builder
msgBuilder.WriteString(fmt.Sprintf("command failed with exit code %d: %s", errExit.ExitCode(), argcmd))
if stderr.Len() > 0 {
msgBuilder.WriteString("\n")
msgBuilder.WriteString(stderr.String())
}
return nil, fmt.Errorf(msgBuilder.String())
}
retcode = errExit.ExitCode()
} else {
return nil, err
}
}
return toValue("completed_subprocess", starlark.StringDict{
"retcode": starlark.MakeInt(retcode),
"stdout": starlark.String(stdout.String()),
"stderr": starlark.String(stderr.String()),
}), nil
}