diff --git a/common/hexec/exec.go b/common/hexec/exec.go index 4f23d20f505..1369e847cde 100644 --- a/common/hexec/exec.go +++ b/common/hexec/exec.go @@ -26,7 +26,10 @@ import ( "strings" "sync" + "github.com/bep/logg" "github.com/cli/safeexec" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/config/security" ) @@ -86,7 +89,7 @@ var WithEnviron = func(env []string) func(c *commandeer) { } // New creates a new Exec using the provided security config. -func New(cfg security.Config, workingDir string) *Exec { +func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec { var baseEnviron []string for _, v := range os.Environ() { k, _ := config.SplitEnvVar(v) @@ -96,9 +99,11 @@ func New(cfg security.Config, workingDir string) *Exec { } return &Exec{ - sc: cfg, - workingDir: workingDir, - baseEnviron: baseEnviron, + sc: cfg, + workingDir: workingDir, + infol: log.InfoCommand("exec"), + baseEnviron: baseEnviron, + newNPXRunnerCache: maps.NewCache[string, func(arg ...any) (Runner, error)](), } } @@ -124,12 +129,14 @@ func SafeCommand(name string, arg ...string) (*exec.Cmd, error) { type Exec struct { sc security.Config workingDir string + infol logg.LevelLogger // os.Environ filtered by the Exec.OsEnviron whitelist filter. baseEnviron []string - npxInit sync.Once - npxAvailable bool + newNPXRunnerCache *maps.Cache[string, func(arg ...any) (Runner, error)] + npxInit sync.Once + npxAvailable bool } func (e *Exec) New(name string, arg ...any) (Runner, error) { @@ -155,25 +162,86 @@ func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner, 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) { - // npx is slow, so first try the common case. - nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name) - _, err := safeexec.LookPath(nodeBinFilename) - if err == nil { - return e.new(name, nodeBinFilename, arg...) + if err := e.sc.CheckAllowedExec(name); err != nil { + return nil, err } - e.checkNpx() - if e.npxAvailable { - r, err := e.npx(name, arg...) - if err == nil { - return r, nil + + 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 := safeexec.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 := safeexec.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 e.New(name, arg...) + + return newRunner(arg...) } const ( diff --git a/config/allconfig/load.go b/config/allconfig/load.go index 16e2019cfa9..999e03645b0 100644 --- a/config/allconfig/load.go +++ b/config/allconfig/load.go @@ -470,7 +470,7 @@ func (l *configLoader) loadModules(configs *Configs, ignoreModuleDoesNotExist bo ignoreVendor, _ = hglob.GetGlob(hglob.NormalizePath(s)) } - ex := hexec.New(conf.Security, workingDir) + ex := hexec.New(conf.Security, workingDir, l.Logger) hook := func(m *modules.ModulesConfig) error { for _, tc := range m.AllModules { diff --git a/deps/deps.go b/deps/deps.go index 56a3d36446a..34c41012cf3 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -188,7 +188,7 @@ func (d *Deps) Init() error { } if d.ExecHelper == nil { - d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir()) + d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir(), d.Log) } if d.MemCache == nil { diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go index 7a6c040b16a..1880aa62ff2 100644 --- a/hugolib/integrationtest_builder.go +++ b/hugolib/integrationtest_builder.go @@ -729,7 +729,7 @@ func (s *IntegrationTestBuilder) initBuilder() error { sc := security.DefaultConfig sc.Exec.Allow, err = security.NewWhitelist("npm") s.Assert(err, qt.IsNil) - ex := hexec.New(sc, s.Cfg.WorkingDir) + ex := hexec.New(sc, s.Cfg.WorkingDir, loggers.NewDefault()) command, err := ex.New("npm", "install") s.Assert(err, qt.IsNil) s.Assert(command.Run(), qt.IsNil) diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go index 08eb2178756..5c4e07498ea 100644 --- a/hugolib/testhelpers_test.go +++ b/hugolib/testhelpers_test.go @@ -838,7 +838,7 @@ func (s *sitesBuilder) NpmInstall() hexec.Runner { var err error sc.Exec.Allow, err = security.NewWhitelist("npm") s.Assert(err, qt.IsNil) - ex := hexec.New(sc, s.workingDir) + ex := hexec.New(sc, s.workingDir, loggers.NewDefault()) command, err := ex.New("npm", "install") s.Assert(err, qt.IsNil) return command diff --git a/markup/asciidocext/convert_test.go b/markup/asciidocext/convert_test.go index b3f63b4d8a4..e93cab00bb8 100644 --- a/markup/asciidocext/convert_test.go +++ b/markup/asciidocext/convert_test.go @@ -313,7 +313,7 @@ allow = ['asciidoctor'] converter.ProviderConfig{ Logger: loggers.NewDefault(), Conf: conf, - Exec: hexec.New(securityConfig, ""), + Exec: hexec.New(securityConfig, "", loggers.NewDefault()), }, ) c.Assert(err, qt.IsNil) diff --git a/markup/pandoc/convert_test.go b/markup/pandoc/convert_test.go index 8beedc1150c..dff6b1ed37a 100644 --- a/markup/pandoc/convert_test.go +++ b/markup/pandoc/convert_test.go @@ -34,7 +34,7 @@ func TestConvert(t *testing.T) { var err error sc.Exec.Allow, err = security.NewWhitelist("pandoc") c.Assert(err, qt.IsNil) - p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, ""), Logger: loggers.NewDefault()}) + p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, "", loggers.NewDefault()), Logger: loggers.NewDefault()}) c.Assert(err, qt.IsNil) conv, err := p.New(converter.DocumentContext{}) c.Assert(err, qt.IsNil) diff --git a/markup/rst/convert_test.go b/markup/rst/convert_test.go index 182858263b8..730e00acf86 100644 --- a/markup/rst/convert_test.go +++ b/markup/rst/convert_test.go @@ -36,7 +36,7 @@ func TestConvert(t *testing.T) { p, err := Provider.New( converter.ProviderConfig{ Logger: loggers.NewDefault(), - Exec: hexec.New(sc, ""), + Exec: hexec.New(sc, "", loggers.NewDefault()), }) c.Assert(err, qt.IsNil) conv, err := p.New(converter.DocumentContext{}) diff --git a/modules/client_test.go b/modules/client_test.go index 0ee7e0dbc40..6320e33385d 100644 --- a/modules/client_test.go +++ b/modules/client_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/hugofs/glob" @@ -61,7 +62,7 @@ github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0 github.com/gohugoio/h WorkingDir: workingDir, ThemesDir: themesDir, PublishDir: publishDir, - Exec: hexec.New(security.DefaultConfig, ""), + Exec: hexec.New(security.DefaultConfig, "", loggers.NewDefault()), } withConfig(&ccfg)