// Copyright 2017 Google Inc. All rights reserved. // // 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 main import ( "bytes" "crypto/sha1" "encoding/hex" "errors" "flag" "fmt" "io" "io/ioutil" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "android/soong/cmd/sbox/sbox_proto" "android/soong/makedeps" "android/soong/response" "google.golang.org/protobuf/encoding/prototext" ) var ( sandboxesRoot string outputDir string manifestFile string keepOutDir bool writeIfChanged bool ) const ( depFilePlaceholder = "__SBOX_DEPFILE__" sandboxDirPlaceholder = "__SBOX_SANDBOX_DIR__" ) func init() { flag.StringVar(&sandboxesRoot, "sandbox-path", "", "root of temp directory to put the sandbox into") flag.StringVar(&outputDir, "output-dir", "", "directory which will contain all output files and only output files") flag.StringVar(&manifestFile, "manifest", "", "textproto manifest describing the sandboxed command(s)") flag.BoolVar(&keepOutDir, "keep-out-dir", false, "whether to keep the sandbox directory when done") flag.BoolVar(&writeIfChanged, "write-if-changed", false, "only write the output files if they have changed") } func usageViolation(violation string) { if violation != "" { fmt.Fprintf(os.Stderr, "Usage error: %s.\n\n", violation) } fmt.Fprintf(os.Stderr, "Usage: sbox --manifest --sandbox-path \n") flag.PrintDefaults() os.Exit(1) } func main() { flag.Usage = func() { usageViolation("") } flag.Parse() error := run() if error != nil { fmt.Fprintln(os.Stderr, error) os.Exit(1) } } func findAllFilesUnder(root string) (paths []string) { paths = []string{} filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if !info.IsDir() { relPath, err := filepath.Rel(root, path) if err != nil { // couldn't find relative path from ancestor? panic(err) } paths = append(paths, relPath) } return nil }) return paths } func run() error { if manifestFile == "" { usageViolation("--manifest is required and must be non-empty") } if sandboxesRoot == "" { // In practice, the value of sandboxesRoot will mostly likely be at a fixed location relative to OUT_DIR, // and the sbox executable will most likely be at a fixed location relative to OUT_DIR too, so // the value of sandboxesRoot will most likely be at a fixed location relative to the sbox executable // However, Soong also needs to be able to separately remove the sandbox directory on startup (if it has anything left in it) // and by passing it as a parameter we don't need to duplicate its value usageViolation("--sandbox-path is required and must be non-empty") } manifest, err := readManifest(manifestFile) if len(manifest.Commands) == 0 { return fmt.Errorf("at least one commands entry is required in %q", manifestFile) } // setup sandbox directory err = os.MkdirAll(sandboxesRoot, 0777) if err != nil { return fmt.Errorf("failed to create %q: %w", sandboxesRoot, err) } // This tool assumes that there are no two concurrent runs with the same // manifestFile. It should therefore be safe to use the hash of the // manifestFile as the temporary directory name. We do this because it // makes the temporary directory name deterministic. There are some // tools that embed the name of the temporary output in the output, and // they otherwise cause non-determinism, which then poisons actions // depending on this one. hash := sha1.New() hash.Write([]byte(manifestFile)) tempDir := filepath.Join(sandboxesRoot, "sbox", hex.EncodeToString(hash.Sum(nil))) err = os.RemoveAll(tempDir) if err != nil { return err } err = os.MkdirAll(tempDir, 0777) if err != nil { return fmt.Errorf("failed to create temporary dir in %q: %w", sandboxesRoot, err) } // In the common case, the following line of code is what removes the sandbox // If a fatal error occurs (such as if our Go process is killed unexpectedly), // then at the beginning of the next build, Soong will wipe the temporary // directory. defer func() { // in some cases we decline to remove the temp dir, to facilitate debugging if !keepOutDir { os.RemoveAll(tempDir) } }() // If there is more than one command in the manifest use a separate directory for each one. useSubDir := len(manifest.Commands) > 1 var commandDepFiles []string for i, command := range manifest.Commands { localTempDir := tempDir if useSubDir { localTempDir = filepath.Join(localTempDir, strconv.Itoa(i)) } depFile, err := runCommand(command, localTempDir, i) if err != nil { // Running the command failed, keep the temporary output directory around in // case a user wants to inspect it for debugging purposes. Soong will delete // it at the beginning of the next build anyway. keepOutDir = true return err } if depFile != "" { commandDepFiles = append(commandDepFiles, depFile) } } outputDepFile := manifest.GetOutputDepfile() if len(commandDepFiles) > 0 && outputDepFile == "" { return fmt.Errorf("Sandboxed commands used %s but output depfile is not set in manifest file", depFilePlaceholder) } if outputDepFile != "" { // Merge the depfiles from each command in the manifest to a single output depfile. err = rewriteDepFiles(commandDepFiles, outputDepFile) if err != nil { return fmt.Errorf("failed merging depfiles: %w", err) } } return nil } // createCommandScript will create and return an exec.Cmd that runs rawCommand. // // rawCommand is executed via a script in the sandbox. // scriptPath is the temporary where the script is created. // scriptPathInSandbox is the path to the script in the sbox environment. // // returns an exec.Cmd that can be ran from within sbox context if no error, or nil if error. // caller must ensure script is cleaned up if function succeeds. func createCommandScript(rawCommand, scriptPath, scriptPathInSandbox string) (*exec.Cmd, error) { err := os.WriteFile(scriptPath, []byte(rawCommand), 0644) if err != nil { return nil, fmt.Errorf("failed to write command %s... to %s", rawCommand[0:40], scriptPath) } return exec.Command("bash", scriptPathInSandbox), nil } // readManifest reads an sbox manifest from a textproto file. func readManifest(file string) (*sbox_proto.Manifest, error) { manifestData, err := ioutil.ReadFile(file) if err != nil { return nil, fmt.Errorf("error reading manifest %q: %w", file, err) } manifest := sbox_proto.Manifest{} err = prototext.Unmarshal(manifestData, &manifest) if err != nil { return nil, fmt.Errorf("error parsing manifest %q: %w", file, err) } return &manifest, nil } // runCommand runs a single command from a manifest. If the command references the // __SBOX_DEPFILE__ placeholder it returns the name of the depfile that was used. func runCommand(command *sbox_proto.Command, tempDir string, commandIndex int) (depFile string, err error) { rawCommand := command.GetCommand() if rawCommand == "" { return "", fmt.Errorf("command is required") } // Remove files from the output directory err = clearOutputDirectory(command.CopyAfter, outputDir, writeType(writeIfChanged)) if err != nil { return "", err } pathToTempDirInSbox := tempDir if command.GetChdir() { pathToTempDirInSbox = "." } err = os.MkdirAll(tempDir, 0777) if err != nil { return "", fmt.Errorf("failed to create %q: %w", tempDir, err) } // Copy in any files specified by the manifest. err = copyFiles(command.CopyBefore, "", tempDir, requireFromExists, alwaysWrite) if err != nil { return "", err } err = copyRspFiles(command.RspFiles, tempDir, pathToTempDirInSbox) if err != nil { return "", err } if strings.Contains(rawCommand, depFilePlaceholder) { depFile = filepath.Join(pathToTempDirInSbox, "deps.d") rawCommand = strings.Replace(rawCommand, depFilePlaceholder, depFile, -1) } if strings.Contains(rawCommand, sandboxDirPlaceholder) { rawCommand = strings.Replace(rawCommand, sandboxDirPlaceholder, pathToTempDirInSbox, -1) } // Emulate ninja's behavior of creating the directories for any output files before // running the command. err = makeOutputDirs(command.CopyAfter, tempDir) if err != nil { return "", err } scriptName := fmt.Sprintf("sbox_command.%d.bash", commandIndex) scriptPath := joinPath(tempDir, scriptName) scriptPathInSandbox := joinPath(pathToTempDirInSbox, scriptName) cmd, err := createCommandScript(rawCommand, scriptPath, scriptPathInSandbox) if err != nil { return "", err } buf := &bytes.Buffer{} cmd.Stdin = os.Stdin cmd.Stdout = buf cmd.Stderr = buf if command.GetChdir() { cmd.Dir = tempDir path := os.Getenv("PATH") absPath, err := makeAbsPathEnv(path) if err != nil { return "", err } err = os.Setenv("PATH", absPath) if err != nil { return "", fmt.Errorf("Failed to update PATH: %w", err) } } err = cmd.Run() if err != nil { // The command failed, do a best effort copy of output files out of the sandbox. This is // especially useful for linters with baselines that print an error message on failure // with a command to copy the output lint errors to the new baseline. Use a copy instead of // a move to leave the sandbox intact for manual inspection copyFiles(command.CopyAfter, tempDir, "", allowFromNotExists, writeType(writeIfChanged)) } // If the command was executed but failed with an error, print a debugging message before // the command's output so it doesn't scroll the real error message off the screen. if exit, ok := err.(*exec.ExitError); ok && !exit.Success() { fmt.Fprintf(os.Stderr, "The failing command was run inside an sbox sandbox in temporary directory\n"+ "%s\n"+ "The failing command line can be found in\n"+ "%s\n", tempDir, scriptPath) } // Write the command's combined stdout/stderr. os.Stdout.Write(buf.Bytes()) if err != nil { return "", err } err = validateOutputFiles(command.CopyAfter, tempDir, outputDir, rawCommand) if err != nil { return "", err } // the created files match the declared files; now move them err = moveFiles(command.CopyAfter, tempDir, "", writeType(writeIfChanged)) if err != nil { return "", err } return depFile, nil } // makeOutputDirs creates directories in the sandbox dir for every file that has a rule to be copied // out of the sandbox. This emulate's Ninja's behavior of creating directories for output files // so that the tools don't have to. func makeOutputDirs(copies []*sbox_proto.Copy, sandboxDir string) error { for _, copyPair := range copies { dir := joinPath(sandboxDir, filepath.Dir(copyPair.GetFrom())) err := os.MkdirAll(dir, 0777) if err != nil { return err } } return nil } // validateOutputFiles verifies that all files that have a rule to be copied out of the sandbox // were created by the command. func validateOutputFiles(copies []*sbox_proto.Copy, sandboxDir, outputDir, rawCommand string) error { var missingOutputErrors []error var incorrectOutputDirectoryErrors []error for _, copyPair := range copies { fromPath := joinPath(sandboxDir, copyPair.GetFrom()) fileInfo, err := os.Stat(fromPath) if err != nil { missingOutputErrors = append(missingOutputErrors, fmt.Errorf("%s: does not exist", fromPath)) continue } if fileInfo.IsDir() { missingOutputErrors = append(missingOutputErrors, fmt.Errorf("%s: not a file", fromPath)) } toPath := copyPair.GetTo() if rel, err := filepath.Rel(outputDir, toPath); err != nil { return err } else if strings.HasPrefix(rel, "../") { incorrectOutputDirectoryErrors = append(incorrectOutputDirectoryErrors, fmt.Errorf("%s is not under %s", toPath, outputDir)) } } const maxErrors = 25 if len(incorrectOutputDirectoryErrors) > 0 { errorMessage := "" more := 0 if len(incorrectOutputDirectoryErrors) > maxErrors { more = len(incorrectOutputDirectoryErrors) - maxErrors incorrectOutputDirectoryErrors = incorrectOutputDirectoryErrors[:maxErrors] } for _, err := range incorrectOutputDirectoryErrors { errorMessage += err.Error() + "\n" } if more > 0 { errorMessage += fmt.Sprintf("...%v more", more) } return errors.New(errorMessage) } if len(missingOutputErrors) > 0 { // find all created files for making a more informative error message createdFiles := findAllFilesUnder(sandboxDir) // build error message errorMessage := "mismatch between declared and actual outputs\n" errorMessage += "in sbox command(" + rawCommand + ")\n\n" errorMessage += "in sandbox " + sandboxDir + ",\n" errorMessage += fmt.Sprintf("failed to create %v files:\n", len(missingOutputErrors)) for _, missingOutputError := range missingOutputErrors { errorMessage += " " + missingOutputError.Error() + "\n" } if len(createdFiles) < 1 { errorMessage += "created 0 files." } else { errorMessage += fmt.Sprintf("did create %v files:\n", len(createdFiles)) creationMessages := createdFiles if len(creationMessages) > maxErrors { creationMessages = creationMessages[:maxErrors] creationMessages = append(creationMessages, fmt.Sprintf("...%v more", len(createdFiles)-maxErrors)) } for _, creationMessage := range creationMessages { errorMessage += " " + creationMessage + "\n" } } return errors.New(errorMessage) } return nil } type existsType bool const ( requireFromExists existsType = false allowFromNotExists = true ) type writeType bool const ( alwaysWrite writeType = false onlyWriteIfChanged = true ) // copyFiles copies files in or out of the sandbox. If exists is allowFromNotExists then errors // caused by a from path not existing are ignored. If write is onlyWriteIfChanged then the output // file is compared to the input file and not written to if it is the same, avoiding updating // the timestamp. func copyFiles(copies []*sbox_proto.Copy, fromDir, toDir string, exists existsType, write writeType) error { for _, copyPair := range copies { fromPath := joinPath(fromDir, copyPair.GetFrom()) toPath := joinPath(toDir, copyPair.GetTo()) err := copyOneFile(fromPath, toPath, copyPair.GetExecutable(), exists, write) if err != nil { return fmt.Errorf("error copying %q to %q: %w", fromPath, toPath, err) } } return nil } // copyOneFile copies a file and its permissions. If forceExecutable is true it adds u+x to the // permissions. If exists is allowFromNotExists it returns nil if the from path doesn't exist. // If write is onlyWriteIfChanged then the output file is compared to the input file and not written to // if it is the same, avoiding updating the timestamp. func copyOneFile(from string, to string, forceExecutable bool, exists existsType, write writeType) error { err := os.MkdirAll(filepath.Dir(to), 0777) if err != nil { return err } stat, err := os.Stat(from) if err != nil { if os.IsNotExist(err) && exists == allowFromNotExists { return nil } return err } perm := stat.Mode() if forceExecutable { perm = perm | 0100 // u+x } if write == onlyWriteIfChanged && filesHaveSameContents(from, to) { return nil } in, err := os.Open(from) if err != nil { return err } defer in.Close() // Remove the target before copying. In most cases the file won't exist, but if there are // duplicate copy rules for a file and the source file was read-only the second copy could // fail. err = os.Remove(to) if err != nil && !os.IsNotExist(err) { return err } out, err := os.Create(to) if err != nil { return err } defer func() { out.Close() if err != nil { os.Remove(to) } }() _, err = io.Copy(out, in) if err != nil { return err } if err = out.Close(); err != nil { return err } if err = os.Chmod(to, perm); err != nil { return err } return nil } // copyRspFiles copies rsp files into the sandbox with path mappings, and also copies the files // listed into the sandbox. func copyRspFiles(rspFiles []*sbox_proto.RspFile, toDir, toDirInSandbox string) error { for _, rspFile := range rspFiles { err := copyOneRspFile(rspFile, toDir, toDirInSandbox) if err != nil { return err } } return nil } // copyOneRspFiles copies an rsp file into the sandbox with path mappings, and also copies the files // listed into the sandbox. func copyOneRspFile(rspFile *sbox_proto.RspFile, toDir, toDirInSandbox string) error { in, err := os.Open(rspFile.GetFile()) if err != nil { return err } defer in.Close() files, err := response.ReadRspFile(in) if err != nil { return err } for i, from := range files { // Convert the real path of the input file into the path inside the sandbox using the // path mappings. to := applyPathMappings(rspFile.PathMappings, from) // Copy the file into the sandbox. err := copyOneFile(from, joinPath(toDir, to), false, requireFromExists, alwaysWrite) if err != nil { return err } // Rewrite the name in the list of files to be relative to the sandbox directory. files[i] = joinPath(toDirInSandbox, to) } // Convert the real path of the rsp file into the path inside the sandbox using the path // mappings. outRspFile := joinPath(toDir, applyPathMappings(rspFile.PathMappings, rspFile.GetFile())) err = os.MkdirAll(filepath.Dir(outRspFile), 0777) if err != nil { return err } out, err := os.Create(outRspFile) if err != nil { return err } defer out.Close() // Write the rsp file with converted paths into the sandbox. err = response.WriteRspFile(out, files) if err != nil { return err } return nil } // applyPathMappings takes a list of path mappings and a path, and returns the path with the first // matching path mapping applied. If the path does not match any of the path mappings then it is // returned unmodified. func applyPathMappings(pathMappings []*sbox_proto.PathMapping, path string) string { for _, mapping := range pathMappings { if strings.HasPrefix(path, mapping.GetFrom()+"/") { return joinPath(mapping.GetTo()+"/", strings.TrimPrefix(path, mapping.GetFrom()+"/")) } } return path } // moveFiles moves files specified by a set of copy rules. It uses os.Rename, so it is restricted // to moving files where the source and destination are in the same filesystem. This is OK for // sbox because the temporary directory is inside the out directory. If write is onlyWriteIfChanged // then the output file is compared to the input file and not written to if it is the same, avoiding // updating the timestamp. Otherwise it always updates the timestamp of the new file. func moveFiles(copies []*sbox_proto.Copy, fromDir, toDir string, write writeType) error { for _, copyPair := range copies { fromPath := joinPath(fromDir, copyPair.GetFrom()) toPath := joinPath(toDir, copyPair.GetTo()) err := os.MkdirAll(filepath.Dir(toPath), 0777) if err != nil { return err } if write == onlyWriteIfChanged && filesHaveSameContents(fromPath, toPath) { continue } err = os.Rename(fromPath, toPath) if err != nil { return err } // Update the timestamp of the output file in case the tool wrote an old timestamp (for example, tar can extract // files with old timestamps). now := time.Now() err = os.Chtimes(toPath, now, now) if err != nil { return err } } return nil } // clearOutputDirectory removes all files in the output directory if write is alwaysWrite, or // any files not listed in copies if write is onlyWriteIfChanged func clearOutputDirectory(copies []*sbox_proto.Copy, outputDir string, write writeType) error { if outputDir == "" { return fmt.Errorf("output directory must be set") } if write == alwaysWrite { // When writing all the output files remove the whole output directory return os.RemoveAll(outputDir) } outputFiles := make(map[string]bool, len(copies)) for _, copyPair := range copies { outputFiles[copyPair.GetTo()] = true } existingFiles := findAllFilesUnder(outputDir) for _, existingFile := range existingFiles { fullExistingFile := filepath.Join(outputDir, existingFile) if !outputFiles[fullExistingFile] { err := os.Remove(fullExistingFile) if err != nil { return fmt.Errorf("failed to remove obsolete output file %s: %w", fullExistingFile, err) } } } return nil } // Rewrite one or more depfiles so that it doesn't include the (randomized) sandbox directory // to an output file. func rewriteDepFiles(ins []string, out string) error { var mergedDeps []string for _, in := range ins { data, err := ioutil.ReadFile(in) if err != nil { return err } deps, err := makedeps.Parse(in, bytes.NewBuffer(data)) if err != nil { return err } mergedDeps = append(mergedDeps, deps.Inputs...) } deps := makedeps.Deps{ // Ninja doesn't care what the output file is, so we can use any string here. Output: "outputfile", Inputs: mergedDeps, } // Make the directory for the output depfile in case it is in a different directory // than any of the output files. outDir := filepath.Dir(out) err := os.MkdirAll(outDir, 0777) if err != nil { return fmt.Errorf("failed to create %q: %w", outDir, err) } return ioutil.WriteFile(out, deps.Print(), 0666) } // joinPath wraps filepath.Join but returns file without appending to dir if file is // absolute. func joinPath(dir, file string) string { if filepath.IsAbs(file) { return file } return filepath.Join(dir, file) } // filesHaveSameContents compares the contents if two files, returning true if they are the same // and returning false if they are different or any errors occur. func filesHaveSameContents(a, b string) bool { // Compare the sizes of the two files statA, err := os.Stat(a) if err != nil { return false } statB, err := os.Stat(b) if err != nil { return false } if statA.Size() != statB.Size() { return false } // Open the two files fileA, err := os.Open(a) if err != nil { return false } defer fileA.Close() fileB, err := os.Open(b) if err != nil { return false } defer fileB.Close() // Compare the files 1MB at a time const bufSize = 1 * 1024 * 1024 bufA := make([]byte, bufSize) bufB := make([]byte, bufSize) remain := statA.Size() for remain > 0 { toRead := int64(bufSize) if toRead > remain { toRead = remain } _, err = io.ReadFull(fileA, bufA[:toRead]) if err != nil { return false } _, err = io.ReadFull(fileB, bufB[:toRead]) if err != nil { return false } if bytes.Compare(bufA[:toRead], bufB[:toRead]) != 0 { return false } remain -= toRead } return true } func makeAbsPathEnv(pathEnv string) (string, error) { pathEnvElements := filepath.SplitList(pathEnv) for i, p := range pathEnvElements { if !filepath.IsAbs(p) { absPath, err := filepath.Abs(p) if err != nil { return "", fmt.Errorf("failed to make PATH entry %q absolute: %w", p, err) } pathEnvElements[i] = absPath } } return strings.Join(pathEnvElements, string(filepath.ListSeparator)), nil }