// Copyright 2020 The Hugo Authors. 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 hexec import ( "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "github.com/bep/logg" "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/config/security" ) var WithDir = func(dir string) func(c *commandeer) { return func(c *commandeer) { c.dir = dir } } var WithContext = func(ctx context.Context) func(c *commandeer) { return func(c *commandeer) { c.ctx = ctx } } var WithStdout = func(w io.Writer) func(c *commandeer) { return func(c *commandeer) { c.stdout = w } } var WithStderr = func(w io.Writer) func(c *commandeer) { return func(c *commandeer) { c.stderr = w } } var WithStdin = func(r io.Reader) func(c *commandeer) { return func(c *commandeer) { c.stdin = r } } var WithEnviron = func(env []string) func(c *commandeer) { return func(c *commandeer) { setOrAppend := func(s string) { k1, _ := config.SplitEnvVar(s) var found bool for i, v := range c.env { k2, _ := config.SplitEnvVar(v) if k1 == k2 { found = true c.env[i] = s } } if !found { c.env = append(c.env, s) } } for _, s := range env { setOrAppend(s) } } } // New creates a new Exec using the provided security config. func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec { var baseEnviron []string for _, v := range os.Environ() { k, _ := config.SplitEnvVar(v) if cfg.Exec.OsEnv.Accept(k) { baseEnviron = append(baseEnviron, v) } } return &Exec{ sc: cfg, workingDir: workingDir, infol: log.InfoCommand("exec"), baseEnviron: baseEnviron, newNPXRunnerCache: maps.NewCache[string, func(arg ...any) (Runner, error)](), } } // IsNotFound reports whether this is an error about a binary not found. func IsNotFound(err error) bool { var notFoundErr *NotFoundError return errors.As(err, ¬FoundErr) } // Exec enforces a security policy for commands run via os/exec. type Exec struct { sc security.Config workingDir string infol logg.LevelLogger // os.Environ filtered by the Exec.OsEnviron whitelist filter. baseEnviron []string newNPXRunnerCache *maps.Cache[string, func(arg ...any) (Runner, error)] npxInit sync.Once npxAvailable bool } func (e *Exec) New(name string, arg ...any) (Runner, error) { return e.new(name, "", arg...) } // New will fail if name is not allowed according to the configured security policy. // Else a configured Runner will be returned ready to be Run. func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner, error) { if err := e.sc.CheckAllowedExec(name); err != nil { return nil, err } env := make([]string, len(e.baseEnviron)) copy(env, e.baseEnviron) cm := &commandeer{ name: name, fullyQualifiedName: fullyQualifiedName, env: env, } return cm.command(arg...) } type binaryLocation int func (b binaryLocation) String() string { switch b { case binaryLocationNodeModules: return "node_modules/.bin" case binaryLocationNpx: return "npx" case binaryLocationPath: return "PATH" } return "unknown" } const ( binaryLocationNodeModules binaryLocation = iota + 1 binaryLocationNpx binaryLocationPath ) // Npx will in order: // 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory. // 2. If not found, and npx is available, run npx --no-install . // 3. Fall back to the PATH. // If name is "tailwindcss", we will try the PATH as the second option. func (e *Exec) Npx(name string, arg ...any) (Runner, error) { if err := e.sc.CheckAllowedExec(name); err != nil { return nil, err } newRunner, err := e.newNPXRunnerCache.GetOrCreate(name, func() (func(...any) (Runner, error), error) { type tryFunc func() func(...any) (Runner, error) tryFuncs := map[binaryLocation]tryFunc{ binaryLocationNodeModules: func() func(...any) (Runner, error) { nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name) _, err := exec.LookPath(nodeBinFilename) if err != nil { return nil } return func(arg2 ...any) (Runner, error) { return e.new(name, nodeBinFilename, arg2...) } }, binaryLocationNpx: func() func(...any) (Runner, error) { e.checkNpx() if !e.npxAvailable { return nil } return func(arg2 ...any) (Runner, error) { return e.npx(name, arg2...) } }, binaryLocationPath: func() func(...any) (Runner, error) { if _, err := exec.LookPath(name); err != nil { return nil } return func(arg2 ...any) (Runner, error) { return e.New(name, arg2...) } }, } locations := []binaryLocation{binaryLocationNodeModules, binaryLocationNpx, binaryLocationPath} if name == "tailwindcss" { // See https://github.com/gohugoio/hugo/issues/13221#issuecomment-2574801253 locations = []binaryLocation{binaryLocationNodeModules, binaryLocationPath, binaryLocationNpx} } for _, loc := range locations { if f := tryFuncs[loc](); f != nil { e.infol.Logf("resolve %q using %s", name, loc) return f, nil } } return nil, &NotFoundError{name: name, method: fmt.Sprintf("in %s", locations[len(locations)-1])} }) if err != nil { return nil, err } return newRunner(arg...) } const ( npxNoInstall = "--no-install" npxBinary = "npx" nodeModulesBinPath = "node_modules/.bin" ) func (e *Exec) checkNpx() { e.npxInit.Do(func() { e.npxAvailable = InPath(npxBinary) }) } // npx is a convenience method to create a Runner running npx --no-install