package read import ( "context" "fmt" "os" "path/filepath" "runtime" "gopkg.in/yaml.v3" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/sysinfo" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile" ) var ( // ErrIncludedTaskfilesCantHaveDotenvs is returned when a included Taskfile contains dotenvs ErrIncludedTaskfilesCantHaveDotenvs = errors.New("task: Included Taskfiles can't have dotenv declarations. Please, move the dotenv declaration to the main Taskfile") defaultTaskfiles = []string{ "Taskfile.yml", "taskfile.yml", "Taskfile.yaml", "taskfile.yaml", "Taskfile.dist.yml", "taskfile.dist.yml", "Taskfile.dist.yaml", "taskfile.dist.yaml", } ) func readTaskfile( node Node, download, offline bool, tempDir string, l *logger.Logger, ) (*taskfile.Taskfile, error) { var b []byte var err error var cache *Cache if node.Remote() { cache, err = NewCache(tempDir) if err != nil { return nil, err } } // If the file is remote, check if we have a cached copy // If we're told to download, skip the cache if node.Remote() && !download { if b, err = cache.read(node); !errors.Is(err, os.ErrNotExist) && err != nil { return nil, err } if b != nil { l.VerboseOutf(logger.Magenta, "task: [%s] Fetched cached copy\n", node.Location()) } } // If the file is remote, we found nothing in the cache and we're not // allowed to download it then we can't do anything. if node.Remote() && b == nil && offline { if b == nil && offline { return nil, &errors.TaskfileCacheNotFound{URI: node.Location()} } } // If we still don't have a copy, get the file in the usual way if b == nil { b, err = node.Read(context.Background()) if err != nil { return nil, err } // If the node was remote, we need to check the checksum if node.Remote() { l.VerboseOutf(logger.Magenta, "task: [%s] Fetched remote copy\n", node.Location()) // Get the checksums checksum := checksum(b) cachedChecksum := cache.readChecksum(node) var msg string if cachedChecksum == "" { // If the checksum doesn't exist, prompt the user to continue msg = fmt.Sprintf("The task you are attempting to run depends on the remote Taskfile at %q.\n--- Make sure you trust the source of this Taskfile before continuing ---\nContinue?", node.Location()) } else if checksum != cachedChecksum { // If there is a cached hash, but it doesn't match the expected hash, prompt the user to continue msg = fmt.Sprintf("The Taskfile at %q has changed since you last used it!\n--- Make sure you trust the source of this Taskfile before continuing ---\nContinue?", node.Location()) } if msg != "" { if err := l.Prompt(logger.Yellow, msg, "n", "y", "yes"); errors.Is(err, logger.ErrPromptCancelled) { return nil, &errors.TaskfileNotTrustedError{URI: node.Location()} } else if err != nil { return nil, err } } // If the hash has changed (or is new), store it in the cache if checksum != cachedChecksum { if err := cache.writeChecksum(node, checksum); err != nil { return nil, err } } } } // If the file is remote and we need to cache it if node.Remote() && download { l.VerboseOutf(logger.Magenta, "task: [%s] Caching downloaded file\n", node.Location()) // Cache the file for later if err = cache.write(node, b); err != nil { return nil, err } } var t taskfile.Taskfile if err := yaml.Unmarshal(b, &t); err != nil { return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(node.Location()), Err: err} } t.Location = node.Location() return &t, nil } // Taskfile reads a Taskfile for a given directory // Uses current dir when dir is left empty. Uses Taskfile.yml // or Taskfile.yaml when entrypoint is left empty func Taskfile( node Node, insecure bool, download bool, offline bool, tempDir string, l *logger.Logger, ) (*taskfile.Taskfile, error) { var _taskfile func(Node) (*taskfile.Taskfile, error) _taskfile = func(node Node) (*taskfile.Taskfile, error) { t, err := readTaskfile(node, download, offline, tempDir, l) if err != nil { return nil, err } // Check that the Taskfile is set and has a schema version if t == nil || t.Version == nil { return nil, &errors.TaskfileVersionNotDefined{URI: node.Location()} } // Annotate any included Taskfile reference with a base directory for resolving relative paths if node, isFileNode := node.(*FileNode); isFileNode { _ = t.Includes.Range(func(key string, includedFile taskfile.IncludedTaskfile) error { // Set the base directory for resolving relative paths, but only if not already set if includedFile.BaseDir == "" { includedFile.BaseDir = node.Dir t.Includes.Set(key, includedFile) } return nil }) } err = t.Includes.Range(func(namespace string, includedTask taskfile.IncludedTaskfile) error { if t.Version.Compare(taskfile.V3) >= 0 { tr := templater.Templater{Vars: t.Vars, RemoveNoValue: true} includedTask = taskfile.IncludedTaskfile{ Taskfile: tr.Replace(includedTask.Taskfile), Dir: tr.Replace(includedTask.Dir), Optional: includedTask.Optional, Internal: includedTask.Internal, Aliases: includedTask.Aliases, AdvancedImport: includedTask.AdvancedImport, Vars: includedTask.Vars, BaseDir: includedTask.BaseDir, } if err := tr.Err(); err != nil { return err } } uri, err := includedTask.FullTaskfilePath() if err != nil { return err } includeReaderNode, err := NewNode(uri, insecure, WithParent(node), WithOptional(includedTask.Optional), ) if err != nil { if includedTask.Optional { return nil } return err } if err := checkCircularIncludes(includeReaderNode); err != nil { return err } includedTaskfile, err := _taskfile(includeReaderNode) if err != nil { if includedTask.Optional { return nil } return err } if t.Version.Compare(taskfile.V3) >= 0 && len(includedTaskfile.Dotenv) > 0 { return ErrIncludedTaskfilesCantHaveDotenvs } if includedTask.AdvancedImport { dir, err := includedTask.FullDirPath() if err != nil { return err } // nolint: errcheck includedTaskfile.Vars.Range(func(k string, v taskfile.Var) error { o := v o.Dir = dir includedTaskfile.Vars.Set(k, o) return nil }) // nolint: errcheck includedTaskfile.Env.Range(func(k string, v taskfile.Var) error { o := v o.Dir = dir includedTaskfile.Env.Set(k, o) return nil }) for _, task := range includedTaskfile.Tasks.Values() { task.Dir = filepathext.SmartJoin(dir, task.Dir) if task.IncludeVars == nil { task.IncludeVars = &taskfile.Vars{} } task.IncludeVars.Merge(includedTask.Vars) task.IncludedTaskfileVars = includedTaskfile.Vars task.IncludedTaskfile = &includedTask } } if err = taskfile.Merge(t, includedTaskfile, &includedTask, namespace); err != nil { return err } if includedTaskfile.Tasks.Get("default") != nil && t.Tasks.Get(namespace) == nil { defaultTaskName := fmt.Sprintf("%s:default", namespace) task := t.Tasks.Get(defaultTaskName) task.Aliases = append(task.Aliases, namespace) task.Aliases = append(task.Aliases, includedTask.Aliases...) t.Tasks.Set(defaultTaskName, task) } return nil }) if err != nil { return nil, err } if t.Version.Compare(taskfile.V3) < 0 { if node, isFileNode := node.(*FileNode); isFileNode { path := filepathext.SmartJoin(node.Dir, fmt.Sprintf("Taskfile_%s.yml", runtime.GOOS)) if _, err = os.Stat(path); err == nil { osNode := &FileNode{ BaseNode: NewBaseNode(WithParent(node)), Entrypoint: path, Dir: node.Dir, } b, err := osNode.Read(context.Background()) if err != nil { return nil, err } var osTaskfile *taskfile.Taskfile if err := yaml.Unmarshal(b, &osTaskfile); err != nil { return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(node.Location()), Err: err} } t.Location = node.Location() if err = taskfile.Merge(t, osTaskfile, nil); err != nil { return nil, err } } } } for _, task := range t.Tasks.Values() { // If the task is not defined, create a new one if task == nil { task = &taskfile.Task{} } // Set the location of the taskfile for each task if task.Location.Taskfile == "" { task.Location.Taskfile = t.Location } } return t, nil } return _taskfile(node) } // Exists will check if a file at the given path Exists. If it does, it will // return the path to it. If it does not, it will search the search for any // files at the given path with any of the default Taskfile files names. If any // of these match a file, the first matching path will be returned. If no files // are found, an error will be returned. func Exists(path string) (string, error) { fi, err := os.Stat(path) if err != nil { return "", err } if fi.Mode().IsRegular() { return filepath.Abs(path) } for _, n := range defaultTaskfiles { fpath := filepathext.SmartJoin(path, n) if _, err := os.Stat(fpath); err == nil { return filepath.Abs(fpath) } } return "", errors.TaskfileNotFoundError{URI: path, Walk: false} } // ExistsWalk will check if a file at the given path exists by calling the // exists function. If a file is not found, it will walk up the directory tree // calling the exists function until it finds a file or reaches the root // directory. On supported operating systems, it will also check if the user ID // of the directory changes and abort if it does. func ExistsWalk(path string) (string, error) { origPath := path owner, err := sysinfo.Owner(path) if err != nil { return "", err } for { fpath, err := Exists(path) if err == nil { return fpath, nil } // Get the parent path/user id parentPath := filepath.Dir(path) parentOwner, err := sysinfo.Owner(parentPath) if err != nil { return "", err } // Error if we reached the root directory and still haven't found a file // OR if the user id of the directory changes if path == parentPath || (parentOwner != owner) { return "", errors.TaskfileNotFoundError{URI: origPath, Walk: false} } owner = parentOwner path = parentPath } } func checkCircularIncludes(node Node) error { if node == nil { return errors.New("task: failed to check for include cycle: node was nil") } if node.Parent() == nil { return errors.New("task: failed to check for include cycle: node.Parent was nil") } curNode := node location := node.Location() for curNode.Parent() != nil { curNode = curNode.Parent() curLocation := curNode.Location() if curLocation == location { return fmt.Errorf("task: include cycle detected between %s <--> %s", curLocation, node.Parent().Location(), ) } } return nil }