cache-dependency-path: |
**/go.sum
**/go.mod
+ - name: Install Node
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "22"
- name: Install Ruby
uses: ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0
with:
-// Copyright 2020 The Hugo Authors. All rights reserved.
+// Copyright 2026 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.
package hexec
import (
+ "bufio"
"bytes"
"context"
"errors"
"os/exec"
"path/filepath"
"regexp"
+ "slices"
"strings"
- "sync"
"github.com/bep/logg"
"github.com/gohugoio/hugo/common/hmaps"
}
return &Exec{
- sc: cfg,
- workingDir: workingDir,
- infol: log.InfoCommand("exec"),
- baseEnviron: baseEnviron,
- newNPXRunnerCache: hmaps.NewCache[string, func(arg ...any) (Runner, error)](),
+ sc: cfg,
+ workingDir: workingDir,
+ infol: log.InfoCommand("exec"),
+ baseEnviron: baseEnviron,
+ nodeRunnerCache: hmaps.NewCache[string, func(arg ...any) (Runner, error)](),
}
}
// os.Environ filtered by the Exec.OsEnviron whitelist filter.
baseEnviron []string
- newNPXRunnerCache *hmaps.Cache[string, func(arg ...any) (Runner, error)]
- npxInit sync.Once
- npxAvailable bool
+ // Additional absolute paths to allow reading from in the Node.js permission model.
+ nodeReadPaths []string
+
+ nodeRunnerCache *hmaps.Cache[string, func(arg ...any) (Runner, error)]
+}
+
+// SetNodeReadPaths sets additional absolute paths to allow reading from
+// in the Node.js permission model (e.g. Hugo module cache directories).
+func (e *Exec) SetNodeReadPaths(paths []string) {
+ e.nodeReadPaths = paths
}
func (e *Exec) New(name string, arg ...any) (Runner, error) {
switch b {
case binaryLocationNodeModules:
return "node_modules/.bin"
- case binaryLocationNpx:
- return "npx"
case binaryLocationPath:
return "PATH"
}
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 <name> <args>.
-// 3. Fall back to the PATH.
-// If name is "tailwindcss", we will try the PATH as the second option.
+// Npx finds and runs a Node.js tool. The binary is located first in
+// WORKINGDIR/node_modules/.bin, then in PATH. The tool is always invoked via
+// "node [--permission <flags>] <script> <args>"; the --permission flags are
+// added when the Node.js permission model is enabled.
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...)
- }
- },
+ if err := e.sc.CheckAllowedExec("node"); err != nil {
+ // Legacy path: We replaced npx with node in v0.161.0, and anyone using these tools with a custom security.exec.allow list
+ // would get an error when upgrading. To avoid this, check for npx as well.
+ if err2 := e.sc.CheckAllowedExec("npx"); err2 != nil {
+ return nil, err
}
+ }
- locations := []binaryLocation{binaryLocationNodeModules, binaryLocationNpx, binaryLocationPath}
- if name == "tailwindcss" {
- // See https://github.com/gohugoio/hugo/issues/13221#issuecomment-2574801253
- locations = []binaryLocation{binaryLocationNodeModules, binaryLocationPath, binaryLocationNpx}
+ newRunner, err := e.nodeRunnerCache.GetOrCreate(name, func() (func(...any) (Runner, error), error) {
+ var resolvedBin string
+ var loc binaryLocation
+
+ nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name)
+ if p, err := exec.LookPath(nodeBinFilename); err == nil {
+ resolvedBin = p
+ loc = binaryLocationNodeModules
+ } else if p, err := exec.LookPath(name); err == nil {
+ resolvedBin = p
+ loc = binaryLocationPath
+ } else {
+ return nil, &NotFoundError{name: name, method: "in PATH"}
}
- for _, loc := range locations {
- if f := tryFuncs[loc](); f != nil {
- e.infol.Logf("resolve %q using %s", name, loc)
- return f, nil
- }
+
+ scriptPath := resolveNodeBin(resolvedBin)
+
+ e.infol.WithFields(logg.Fields{
+ logg.Field{Name: "location", Value: loc},
+ logg.Field{Name: "bin", Value: resolvedBin},
+ logg.Field{Name: "script", Value: scriptPath},
+ }).Logf("resolve %q", name)
+
+ if scriptPath == "" {
+ return nil, fmt.Errorf("binary %q is not a Node.js script", name)
}
- return nil, &NotFoundError{name: name, method: fmt.Sprintf("in %s", locations[len(locations)-1])}
+
+ return func(arg2 ...any) (Runner, error) {
+ return e.newNode(name, scriptPath, arg2...)
+ }, nil
})
if err != nil {
return nil, err
return newRunner(arg...)
}
-const (
- npxNoInstall = "--no-install"
- npxBinary = "npx"
- nodeModulesBinPath = "node_modules/.bin"
-)
+// newNode runs a Node.js script via "node [--permission <flags>] <scriptPath> <args>".
+func (e *Exec) newNode(name, scriptPath string, arg ...any) (Runner, error) {
+ var allArgs []any
+ for _, pa := range e.nodePermissionArgs(name, scriptPath) {
+ allArgs = append(allArgs, pa)
+ }
+ allArgs = append(allArgs, scriptPath)
+ allArgs = append(allArgs, arg...)
+ // When the script lives outside the working dir (a globally installed
+ // tool), point NODE_PATH at the script's node_modules ancestor so Node's
+ // resolver (and tools that honor it, e.g. tailwindcss v4) can locate the
+ // tool's sibling packages. tailwindcss v4's CSS resolver treats NODE_PATH
+ // as a single path, not a list, so we don't concatenate with the local
+ // path here. For local installs the caller's NODE_PATH (set by
+ // hugo.GetExecEnviron to <workDir>/node_modules) already covers the need.
+ localNM := filepath.Join(e.workingDir, "node_modules")
+ if p := nodeScriptReadPath(scriptPath); p != "" && p != localNM {
+ allArgs = append(allArgs, WithEnviron([]string{"NODE_PATH=" + p}))
+ }
-func (e *Exec) checkNpx() {
- e.npxInit.Do(func() {
- e.npxAvailable = InPath(npxBinary)
- })
+ return e.New("node", allArgs...)
+}
+
+// nodePermissionArgs builds the Node.js --permission flags from the security config.
+func (e *Exec) nodePermissionArgs(name, scriptPath string) []string {
+ perms := e.sc.Node.Permissions
+ if !perms.IsEnabled() {
+ return nil
+ }
+
+ args := []string{"--permission"}
+
+ for _, p := range e.resolveNodePermPaths(perms.AllowRead) {
+ args = append(args, "--allow-fs-read="+p)
+ }
+ for _, p := range e.nodeReadPaths {
+ args = append(args, "--allow-fs-read="+p)
+ }
+ if p := nodeScriptReadPath(scriptPath); p != "" {
+ args = append(args, "--allow-fs-read="+p)
+ }
+
+ for _, p := range e.resolveNodePermPaths(perms.AllowWrite) {
+ args = append(args, "--allow-fs-write="+p)
+ }
+
+ var silenceSecurityWarnings bool
+ if slices.Contains(perms.AllowAddons, name) {
+ silenceSecurityWarnings = true
+ args = append(args, "--allow-addons")
+ }
+
+ if slices.Contains(perms.AllowWorker, name) {
+ silenceSecurityWarnings = true
+ args = append(args, "--allow-worker")
+ }
+
+ if silenceSecurityWarnings {
+ // There are no more fine grained way to do this, see https://github.com/nodejs/node/issues/59818
+ // If the process is configured to allow either workers or addons, Node will print warnings that's not very helpful.
+ args = append(args, "--disable-warning=SecurityWarning")
+ }
+
+ return args
}
-// npx is a convenience method to create a Runner running npx --no-install <name> <args.
-func (e *Exec) npx(name string, arg ...any) (Runner, error) {
- arg = append(arg[:0], append([]any{npxNoInstall, name}, arg[0:]...)...)
- return e.New(npxBinary, arg...)
+// resolveNodePermPaths resolves relative paths against the working directory.
+func (e *Exec) resolveNodePermPaths(paths []string) []string {
+ resolved := make([]string, len(paths))
+ for i, p := range paths {
+ switch {
+ case p == "*":
+ resolved[i] = "*"
+ case filepath.IsAbs(p):
+ resolved[i] = p
+ default:
+ resolved[i] = filepath.Join(e.workingDir, p)
+ }
+ }
+ return resolved
+}
+
+const nodeModulesBinPath = "node_modules/.bin"
+
+// nodeScriptReadPath returns a path to add to the Node.js read allow-list so
+// a script can load its dependencies. For scripts inside a node_modules tree
+// it returns the nearest ancestor "node_modules" directory, so both nested
+// and hoisted deps are reachable. Otherwise the script's own directory.
+func nodeScriptReadPath(scriptPath string) string {
+ if scriptPath == "" {
+ return ""
+ }
+ dir := filepath.Dir(scriptPath)
+ for {
+ if filepath.Base(dir) == "node_modules" {
+ return dir
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return filepath.Dir(scriptPath)
+ }
+ dir = parent
+ }
+}
+
+// resolveNodeBin resolves a binary path to the underlying Node.js script.
+// Returns the path to the JS entry point, or "" if the binary is not a Node script.
+func resolveNodeBin(path string) string {
+ // 1. If the file is a symlink, resolve it (macOS/Linux npm creates symlinks in node_modules/.bin).
+ if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
+ if hasJSExtension(resolved) || isNodeScript(resolved) {
+ return resolved
+ }
+ }
+ return ""
+ }
+ // 2. Check if the file itself is a Node script (e.g. globally installed with #!/usr/bin/env node).
+ if isNodeScript(path) {
+ return path
+ }
+ // 3. Try extracting JS entry point from an npm wrapper script (.cmd or shell).
+ return extractNodeEntryPoint(path)
+}
+
+// nodeEntryPointRe matches a relative path in npm-generated wrapper scripts.
+// The entry may be a .js/.mjs/.cjs file or an extensionless Node shebang
+// script (e.g. postcss-cli 7's bin/postcss). Local installs reference the
+// entry via "..", global installs via "node_modules" (notably on Windows,
+// where npm does not symlink global binaries).
+// Examples:
+//
+// Local shell: "$basedir/../postcss-cli/index.js"
+// Local cmd: "%dp0%\..\postcss-cli\index.js"
+// Scoped: "$basedir/../@babel/cli/bin/babel.js"
+// No ext: "%dp0%\..\postcss-cli\bin\postcss"
+// Global shell: "$basedir/node_modules/postcss-cli/index.js"
+// Global cmd: "%dp0%\node_modules\postcss-cli\index.js"
+var nodeEntryPointRe = regexp.MustCompile(`[/\\]((?:\.\.|node_modules)[/\\][\w@][\w@./\\-]*)`)
+
+// extractNodeEntryPoint reads an npm wrapper script and extracts the Node
+// entry point path, validating that it's a JS file or a Node shebang script.
+func extractNodeEntryPoint(wrapperPath string) string {
+ data, err := os.ReadFile(wrapperPath)
+ if err != nil {
+ return ""
+ }
+ m := nodeEntryPointRe.FindSubmatch(data)
+ if m == nil {
+ return ""
+ }
+ // Normalize backslashes from Windows .cmd wrappers.
+ relPath := strings.ReplaceAll(string(m[1]), "\\", "/")
+ resolved := filepath.Join(filepath.Dir(wrapperPath), relPath)
+ if _, err := os.Stat(resolved); err != nil {
+ return ""
+ }
+ if !hasJSExtension(resolved) && !isNodeScript(resolved) {
+ return ""
+ }
+ return resolved
+}
+
+func hasJSExtension(path string) bool {
+ switch filepath.Ext(path) {
+ case ".js", ".mjs", ".cjs":
+ return true
+ }
+ return false
+}
+
+// isNodeScript reports whether the file at path has a Node.js shebang.
+func isNodeScript(path string) bool {
+ f, err := os.Open(path)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+ r := bufio.NewReader(f)
+ line, err := r.ReadString('\n')
+ if err != nil && len(line) == 0 {
+ return false
+ }
+ return strings.HasPrefix(line, "#!") && strings.Contains(line, "node")
}
// Sec returns the security policies this Exec is configured with.
if err == nil {
return nil
}
- name := c.name
- method := "in PATH"
- if name == npxBinary {
- name = c.c.Args[2]
- method = "using npx"
- }
if notFoundRe.MatchString(c.outerr.String()) {
- return &NotFoundError{name: name, method: method}
+ return &NotFoundError{name: c.name, method: "in PATH"}
}
return fmt.Errorf("failed to execute binary %q with args %v: %s", c.name, c.c.Args[1:], c.outerr.String())
}
--- /dev/null
+// Copyright 2026 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_test
+
+import (
+ "testing"
+
+ "github.com/gohugoio/hugo/htesting"
+ "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestNPMGlobalInstalls(t *testing.T) {
+ if !htesting.IsRealCI() {
+ t.Skip("We only ever want to run this in CI.")
+ }
+ files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term"]
+[security.exec]
+allow = ['^(babel|node|postcss|tailwindcss)$']
+
+-- package.json --
+{}
+-- hugo_stats.json --
+-- assets/js/main.js --
+console.log("Hello, world!");
+-- assets/css/main1.css --
+body { color: red }
+-- assets/css/main2.css --
+@import "tailwindcss";
+@plugin "@tailwindcss/typography";
+@source "hugo_stats.json";
+body { color: blue }
+-- layouts/home.html --
+{{ with resources.Get "css/main1.css" }}
+ {{ with . | css.PostCSS }}
+ CSS1 size: {{ .Content | len }}|{{ .RelPermalink }}|
+ {{ end }}
+{{ end }}
+ {{ with resources.Get "css/main2.css" }}
+ {{ with . | css.TailwindCSS }}
+ CSS2 size: {{ .Content | len }}|{{ .RelPermalink }}|
+ {{ end }}
+{{ end }}
+{{ with resources.Get "js/main.js" }}
+ {{ with . | js.Babel }}
+ JS size: {{ .Content | len }}|{{ .RelPermalink }}|
+ {{ end }}
+{{ end }}
+`
+
+ b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWithNpmInstallGlobal(
+ "postcss", "postcss-cli",
+ "@babel/core", "@babel/cli",
+ "tailwindcss", "@tailwindcss/cli", "@tailwindcss/typography",
+ ))
+
+ b.AssertFileContent("public/index.html",
+ "CSS1 size: 233|/css/main1.css|",
+ "CSS2 size: 4557|/css/main2.css|",
+ "JS size: 31|/js/main.js|",
+ )
+}
--- /dev/null
+// Copyright 2026 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 (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/gohugoio/hugo/config/security"
+)
+
+func TestNodePermissionArgs(t *testing.T) {
+ c := qt.New(t)
+
+ // Use t.TempDir() so paths are absolute on any OS (avoids Windows volume assumptions).
+ base := t.TempDir()
+ site := filepath.Join(base, "site")
+ tmp := filepath.Join(base, "tmp")
+ cacheDir := filepath.Join(base, "home", "user", ".cache", "hugo_cache", "modules")
+
+ c.Run("Default config tailwindcss", func(c *qt.C) {
+ e := &Exec{
+ sc: security.DefaultConfig,
+ workingDir: site,
+ }
+ args := e.nodePermissionArgs("tailwindcss", "")
+ c.Assert(args, qt.DeepEquals, []string{
+ "--permission",
+ "--allow-fs-read=" + site,
+ "--allow-addons",
+ "--allow-worker",
+ "--disable-warning=SecurityWarning",
+ })
+ })
+
+ c.Run("Default config postcss", func(c *qt.C) {
+ e := &Exec{
+ sc: security.DefaultConfig,
+ workingDir: site,
+ }
+ args := e.nodePermissionArgs("postcss", "")
+ c.Assert(args, qt.DeepEquals, []string{
+ "--permission",
+ "--allow-fs-read=" + site,
+ })
+ })
+
+ c.Run("Multiple paths", func(c *qt.C) {
+ cfg := security.DefaultConfig
+ cfg.Node.Permissions.AllowRead = []string{".", tmp}
+ cfg.Node.Permissions.AllowWrite = []string{"."}
+ e := &Exec{
+ sc: cfg,
+ workingDir: site,
+ }
+ args := e.nodePermissionArgs("tailwindcss", "")
+ c.Assert(args, qt.DeepEquals, []string{
+ "--permission",
+ "--allow-fs-read=" + site,
+ "--allow-fs-read=" + tmp,
+ "--allow-fs-write=" + site,
+ "--allow-addons",
+ "--allow-worker",
+ "--disable-warning=SecurityWarning",
+ })
+ })
+
+ c.Run("Wildcard", func(c *qt.C) {
+ cfg := security.DefaultConfig
+ cfg.Node.Permissions.AllowRead = []string{"*"}
+ cfg.Node.Permissions.AllowWrite = []string{"*"}
+ e := &Exec{
+ sc: cfg,
+ workingDir: site,
+ }
+ args := e.nodePermissionArgs("tailwindcss", "")
+ c.Assert(args, qt.DeepEquals, []string{
+ "--permission",
+ "--allow-fs-read=*",
+ "--allow-fs-write=*",
+ "--allow-addons",
+ "--allow-worker",
+ "--disable-warning=SecurityWarning",
+ })
+ })
+
+ c.Run("Disabled", func(c *qt.C) {
+ cfg := security.DefaultConfig
+ cfg.Node.Permissions.Disable = true
+ e := &Exec{
+ sc: cfg,
+ workingDir: site,
+ }
+ args := e.nodePermissionArgs("tailwindcss", "")
+ c.Assert(args, qt.IsNil)
+ })
+
+ c.Run("No fs flags", func(c *qt.C) {
+ cfg := security.DefaultConfig
+ cfg.Node.Permissions.AllowRead = nil
+ cfg.Node.Permissions.AllowAddons = nil
+ cfg.Node.Permissions.AllowWorker = nil
+ e := &Exec{
+ sc: cfg,
+ workingDir: site,
+ }
+ args := e.nodePermissionArgs("postcss", "")
+ c.Assert(args, qt.DeepEquals, []string{"--permission"})
+ })
+
+ c.Run("Read only", func(c *qt.C) {
+ cfg := security.DefaultConfig
+ cfg.Node.Permissions.AllowRead = []string{"."}
+ cfg.Node.Permissions.AllowWrite = nil
+ e := &Exec{
+ sc: cfg,
+ workingDir: site,
+ }
+ args := e.nodePermissionArgs("postcss", "")
+ c.Assert(args, qt.DeepEquals, []string{
+ "--permission",
+ "--allow-fs-read=" + site,
+ })
+ })
+
+ c.Run("With additional read paths", func(c *qt.C) {
+ e := &Exec{
+ sc: security.DefaultConfig,
+ workingDir: site,
+ nodeReadPaths: []string{cacheDir},
+ }
+ args := e.nodePermissionArgs("postcss", "")
+ c.Assert(args, qt.DeepEquals, []string{
+ "--permission",
+ "--allow-fs-read=" + site,
+ "--allow-fs-read=" + cacheDir,
+ })
+ })
+
+ c.Run("Global install script path", func(c *qt.C) {
+ e := &Exec{
+ sc: security.DefaultConfig,
+ workingDir: site,
+ }
+ globalNM := filepath.Join(base, "nvm", "lib", "node_modules")
+ script := filepath.Join(globalNM, "postcss-cli", "bin", "postcss")
+ args := e.nodePermissionArgs("postcss", script)
+ c.Assert(args, qt.DeepEquals, []string{
+ "--permission",
+ "--allow-fs-read=" + site,
+ "--allow-fs-read=" + globalNM,
+ })
+ })
+}
+
+func TestNodeScriptReadPath(t *testing.T) {
+ c := qt.New(t)
+
+ base := t.TempDir()
+ nm := filepath.Join(base, "node_modules")
+ globalNM := filepath.Join(base, "nvm", "lib", "node_modules")
+
+ c.Assert(nodeScriptReadPath(""), qt.Equals, "")
+ c.Assert(nodeScriptReadPath(filepath.Join(nm, "postcss-cli", "index.js")), qt.Equals, nm)
+ c.Assert(nodeScriptReadPath(filepath.Join(nm, "@babel", "cli", "bin", "babel.js")), qt.Equals, nm)
+ c.Assert(nodeScriptReadPath(filepath.Join(globalNM, "postcss-cli", "bin", "postcss")), qt.Equals, globalNM)
+
+ loose := filepath.Join(base, "tools", "script.js")
+ c.Assert(nodeScriptReadPath(loose), qt.Equals, filepath.Dir(loose))
+}
+
+func TestResolveNodeBin(t *testing.T) {
+ c := qt.New(t)
+
+ // Create a fake node_modules structure.
+ dir := t.TempDir()
+ nodeModules := filepath.Join(dir, "node_modules")
+ binDir := filepath.Join(nodeModules, ".bin")
+
+ // Create target JS files.
+ postcssJS := filepath.Join(nodeModules, "postcss-cli", "index.js")
+ babelJS := filepath.Join(nodeModules, "@babel", "cli", "bin", "babel.js")
+ mkdirAndWrite(t, postcssJS, "#!/usr/bin/env node\nconsole.log('postcss');\n")
+ mkdirAndWrite(t, babelJS, "#!/usr/bin/env node\nconsole.log('babel');\n")
+ os.MkdirAll(binDir, 0o755)
+
+ c.Run("Symlink to JS file", func(c *qt.C) {
+ if runtime.GOOS == "windows" {
+ c.Skip("Symlinks may require elevated privileges on Windows")
+ }
+ link := filepath.Join(binDir, "postcss-link")
+ os.Remove(link)
+ c.Assert(os.Symlink(postcssJS, link), qt.IsNil)
+
+ resolved := resolveNodeBin(link)
+ t.Logf("Symlink: link=%q, resolved=%q", link, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
+ })
+
+ c.Run("Symlink to scoped package", func(c *qt.C) {
+ if runtime.GOOS == "windows" {
+ c.Skip("Symlinks may require elevated privileges on Windows")
+ }
+ link := filepath.Join(binDir, "babel-link")
+ os.Remove(link)
+ c.Assert(os.Symlink(babelJS, link), qt.IsNil)
+
+ resolved := resolveNodeBin(link)
+ t.Logf("Scoped symlink: link=%q, resolved=%q", link, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, babelJS), qt.IsTrue)
+ })
+
+ c.Run("Shell wrapper", func(c *qt.C) {
+ wrapper := filepath.Join(binDir, "postcss-sh")
+ content := "#!/bin/sh\n" +
+ `basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")` + "\n" +
+ `exec node "$basedir/../postcss-cli/index.js" "$@"` + "\n"
+ mkdirAndWrite(t, wrapper, content)
+
+ resolved := resolveNodeBin(wrapper)
+ t.Logf("Shell wrapper: wrapper=%q, resolved=%q", wrapper, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
+ })
+
+ c.Run("Cmd wrapper", func(c *qt.C) {
+ wrapper := filepath.Join(binDir, "postcss.cmd")
+ content := "@ECHO off\r\n" +
+ "SETLOCAL\r\n" +
+ `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\postcss-cli\index.js" %*` + "\r\n"
+ mkdirAndWrite(t, wrapper, content)
+
+ resolved := resolveNodeBin(wrapper)
+ t.Logf("Cmd wrapper: wrapper=%q, resolved=%q", wrapper, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
+ })
+
+ c.Run("Cmd wrapper scoped package", func(c *qt.C) {
+ wrapper := filepath.Join(binDir, "babel.cmd")
+ content := "@ECHO off\r\n" +
+ `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\@babel\cli\bin\babel.js" %*` + "\r\n"
+ mkdirAndWrite(t, wrapper, content)
+
+ resolved := resolveNodeBin(wrapper)
+ t.Logf("Cmd wrapper (scoped): wrapper=%q, resolved=%q", wrapper, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, babelJS), qt.IsTrue)
+ })
+
+ c.Run("Node script with shebang", func(c *qt.C) {
+ script := filepath.Join(binDir, "node-global")
+ mkdirAndWrite(t, script, "#!/usr/bin/env node\nconsole.log('global');\n")
+
+ resolved := resolveNodeBin(script)
+ t.Logf("Node script: path=%q, resolved=%q", script, resolved)
+ c.Assert(resolved, qt.Equals, script)
+ })
+
+ c.Run("Native binary", func(c *qt.C) {
+ native := filepath.Join(binDir, "native-tool")
+ mkdirAndWrite(t, native, "\x7fELF\x00\x00\x00")
+
+ resolved := resolveNodeBin(native)
+ t.Logf("Native binary: path=%q, resolved=%q", native, resolved)
+ c.Assert(resolved, qt.Equals, "")
+ })
+
+ c.Run("Nonexistent file", func(c *qt.C) {
+ c.Assert(resolveNodeBin("/nonexistent/path"), qt.Equals, "")
+ })
+
+ c.Run("Wrapper with missing target", func(c *qt.C) {
+ wrapper := filepath.Join(binDir, "missing-target")
+ mkdirAndWrite(t, wrapper, "#!/bin/sh\nexec node \"$basedir/../no-such-pkg/index.js\" \"$@\"\n")
+
+ resolved := resolveNodeBin(wrapper)
+ t.Logf("Missing target: wrapper=%q, resolved=%q", wrapper, resolved)
+ c.Assert(resolved, qt.Equals, "")
+ })
+}
+
+func TestExtractNodeEntryPointRegex(t *testing.T) {
+ c := qt.New(t)
+
+ cases := []struct {
+ name string
+ content string
+ want string // expected capture group (with original separators)
+ }{
+ {"shell postcss", `"$basedir/../postcss-cli/index.js"`, "../postcss-cli/index.js"},
+ {"shell babel", `"$basedir/../@babel/cli/bin/babel.js"`, "../@babel/cli/bin/babel.js"},
+ {"shell tailwind mjs", `"$basedir/../@tailwindcss/cli/dist/index.mjs"`, "../@tailwindcss/cli/dist/index.mjs"},
+ {"cmd postcss", `"%dp0%\..\postcss-cli\index.js"`, `..\postcss-cli\index.js`},
+ {"cmd babel", `"%dp0%\..\@babel\cli\bin\babel.js"`, `..\@babel\cli\bin\babel.js`},
+ {"cmd postcss no ext", `"%dp0%\..\postcss-cli\bin\postcss"`, `..\postcss-cli\bin\postcss`},
+ {"shell postcss no ext", `"$basedir/../postcss-cli/bin/postcss"`, "../postcss-cli/bin/postcss"},
+ {"cmd postcss global", `"%dp0%\node_modules\postcss-cli\index.js"`, `node_modules\postcss-cli\index.js`},
+ {"cmd babel global", `"%dp0%\node_modules\@babel\cli\bin\babel.js"`, `node_modules\@babel\cli\bin\babel.js`},
+ {"shell postcss global", `"$basedir/node_modules/postcss-cli/index.js"`, "node_modules/postcss-cli/index.js"},
+ }
+
+ for _, tc := range cases {
+ c.Run(tc.name, func(c *qt.C) {
+ m := nodeEntryPointRe.FindStringSubmatch(tc.content)
+ t.Logf("regex match for %q: %v", tc.name, m)
+ c.Assert(m, qt.Not(qt.IsNil))
+ c.Assert(m[1], qt.Equals, tc.want)
+ })
+ }
+
+ c.Run("No match", func(c *qt.C) {
+ for _, s := range []string{"@ECHO off", "#!/bin/bash\necho hello", "\x7fELF"} {
+ c.Assert(nodeEntryPointRe.FindStringSubmatch(s), qt.IsNil)
+ }
+ })
+}
+
+// TestResolveNodeBinWindows tests wrapper resolution on all platforms
+// by simulating Windows-style wrapper files. On Windows CI, this also
+// tests the native .cmd resolution path.
+func TestResolveNodeBinWindows(t *testing.T) {
+ c := qt.New(t)
+
+ dir := t.TempDir()
+ nodeModules := filepath.Join(dir, "node_modules")
+ binDir := filepath.Join(nodeModules, ".bin")
+
+ // Create target JS file.
+ targetJS := filepath.Join(nodeModules, "postcss-cli", "index.js")
+ mkdirAndWrite(t, targetJS, "#!/usr/bin/env node\nconsole.log('postcss');\n")
+ os.MkdirAll(binDir, 0o755)
+
+ // Simulate what npm creates on Windows: a .cmd wrapper and a shell script.
+ cmdWrapper := filepath.Join(binDir, "postcss.cmd")
+ cmdContent := "@ECHO off\r\n" +
+ "SETLOCAL\r\n" +
+ "CALL :find_dp0\r\n" +
+ `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\postcss-cli\index.js" %*` + "\r\n"
+ mkdirAndWrite(t, cmdWrapper, cmdContent)
+
+ shWrapper := filepath.Join(binDir, "postcss")
+ shContent := "#!/bin/sh\n" +
+ `exec node "$basedir/../postcss-cli/index.js" "$@"` + "\n"
+ mkdirAndWrite(t, shWrapper, shContent)
+
+ t.Logf("GOOS=%s", runtime.GOOS)
+ t.Logf("cmd wrapper: %s", cmdWrapper)
+ t.Logf("sh wrapper: %s", shWrapper)
+ t.Logf("target JS: %s", targetJS)
+
+ c.Run("cmd wrapper resolves to JS", func(c *qt.C) {
+ resolved := resolveNodeBin(cmdWrapper)
+ t.Logf("resolveNodeBin(%q) = %q", cmdWrapper, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, targetJS), qt.IsTrue)
+ })
+
+ c.Run("sh wrapper resolves to JS", func(c *qt.C) {
+ resolved := resolveNodeBin(shWrapper)
+ t.Logf("resolveNodeBin(%q) = %q", shWrapper, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, targetJS), qt.IsTrue)
+ })
+
+ // Simulate postcss-cli 7, whose wrappers point to an extensionless Node
+ // shebang script (bin/postcss) rather than a .js file.
+ targetNoExt := filepath.Join(nodeModules, "postcss-cli", "bin", "postcss")
+ mkdirAndWrite(t, targetNoExt, "#!/usr/bin/env node\nrequire('../');\n")
+
+ cmdNoExt := filepath.Join(binDir, "postcssne.cmd")
+ mkdirAndWrite(t, cmdNoExt, "@ECHO off\r\n"+
+ `"%dp0%\..\postcss-cli\bin\postcss" %*`+"\r\n")
+
+ shNoExt := filepath.Join(binDir, "postcssne")
+ mkdirAndWrite(t, shNoExt, "#!/bin/sh\n"+
+ `exec node "$basedir/../postcss-cli/bin/postcss" "$@"`+"\n")
+
+ c.Run("cmd wrapper resolves to extensionless script", func(c *qt.C) {
+ resolved := resolveNodeBin(cmdNoExt)
+ t.Logf("resolveNodeBin(%q) = %q", cmdNoExt, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, targetNoExt), qt.IsTrue)
+ })
+
+ c.Run("sh wrapper resolves to extensionless script", func(c *qt.C) {
+ resolved := resolveNodeBin(shNoExt)
+ t.Logf("resolveNodeBin(%q) = %q", shNoExt, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, targetNoExt), qt.IsTrue)
+ })
+
+ // Simulate `npm install -g` on Windows: the wrapper sits at the npm
+ // global prefix and references node_modules as a child (no `..`).
+ globalDir := filepath.Join(dir, "global")
+ globalTarget := filepath.Join(globalDir, "node_modules", "postcss-cli", "index.js")
+ mkdirAndWrite(t, globalTarget, "#!/usr/bin/env node\nconsole.log('postcss');\n")
+
+ globalCmd := filepath.Join(globalDir, "postcss.cmd")
+ mkdirAndWrite(t, globalCmd, "@ECHO off\r\n"+
+ `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\postcss-cli\index.js" %*`+"\r\n")
+
+ globalSh := filepath.Join(globalDir, "postcss")
+ mkdirAndWrite(t, globalSh, "#!/bin/sh\n"+
+ `exec node "$basedir/node_modules/postcss-cli/index.js" "$@"`+"\n")
+
+ c.Run("global cmd wrapper resolves to JS", func(c *qt.C) {
+ resolved := resolveNodeBin(globalCmd)
+ t.Logf("resolveNodeBin(%q) = %q", globalCmd, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, globalTarget), qt.IsTrue)
+ })
+
+ c.Run("global sh wrapper resolves to JS", func(c *qt.C) {
+ resolved := resolveNodeBin(globalSh)
+ t.Logf("resolveNodeBin(%q) = %q", globalSh, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, globalTarget), qt.IsTrue)
+ })
+
+ globalScopedTarget := filepath.Join(globalDir, "node_modules", "@babel", "cli", "bin", "babel.js")
+ mkdirAndWrite(t, globalScopedTarget, "#!/usr/bin/env node\nconsole.log('babel');\n")
+ globalScopedCmd := filepath.Join(globalDir, "babel.cmd")
+ mkdirAndWrite(t, globalScopedCmd, "@ECHO off\r\n"+
+ `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\@babel\cli\bin\babel.js" %*`+"\r\n")
+
+ c.Run("global cmd wrapper scoped package", func(c *qt.C) {
+ resolved := resolveNodeBin(globalScopedCmd)
+ t.Logf("resolveNodeBin(%q) = %q", globalScopedCmd, resolved)
+ c.Assert(resolved, qt.Not(qt.Equals), "")
+ c.Assert(sameFile(t, resolved, globalScopedTarget), qt.IsTrue)
+ })
+}
+
+func sameFile(t *testing.T, a, b string) bool {
+ t.Helper()
+ infoA, errA := os.Stat(a)
+ infoB, errB := os.Stat(b)
+ if errA != nil || errB != nil {
+ t.Logf("sameFile: stat errors: a=%v, b=%v", errA, errB)
+ return false
+ }
+ return os.SameFile(infoA, infoB)
+}
+
+func mkdirAndWrite(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
+ t.Fatal(err)
+ }
+}
var env []string
nodepath := filepath.Join(workDir, "node_modules")
if np := os.Getenv("NODE_PATH"); np != "" {
- nodepath = workDir + string(os.PathListSeparator) + np
+ nodepath = nodepath + string(os.PathListSeparator) + np
}
config.SetEnvVars(&env, "NODE_PATH", nodepath)
config.SetEnvVars(&env, "PWD", workDir)
"errors"
"fmt"
"reflect"
+ "slices"
"strings"
"github.com/gohugoio/hugo/common/herrors"
"^(dart-)?sass(-embedded)?$", // sass, dart-sass, dart-sass-embedded.
"^go$", // for Go Modules
"^git$", // For Git info
- "^npx$", // used by all Node tools (Babel, PostCSS).
+ "^node$", // Used as the runtime for Node tools.
"^postcss$",
"^tailwindcss$",
),
URLs: MustNewWhitelist(".*"),
Methods: MustNewWhitelist("(?i)GET|POST"),
},
+ Node: Node{
+ Permissions: NodePermissions{
+ Disable: false,
+ AllowRead: []string{"."},
+ AllowWrite: []string{}, // No write access by default.
+ AllowAddons: []string{"tailwindcss"}, // tailwindcss does not work without addon permissions.
+ AllowWorker: []string{"tailwindcss"}, // tailwindcss needs worker access.
+ },
+ },
}
// Config is the top level security config.
// Restricts access to resources.GetRemote, getJSON, getCSV.
HTTP HTTP `json:"http"`
+ // Node holds Node.js security settings.
+ Node Node `json:"node"`
+
// Allow inline shortcodes
EnableInlineShortcodes bool `json:"enableInlineShortcodes"`
}
MediaTypes Whitelist `json:"mediaTypes"`
}
+// Node holds Node.js security settings.
+type Node struct {
+ // Permissions configures Node's --permission flag for file system access control.
+ Permissions NodePermissions `json:"permissions"`
+}
+
+// NodePermissions configures the Node.js permission model (--permission).
+// Paths are relative to the working directory; "." means the working directory itself.
+// Use "*" to allow all paths.
+type NodePermissions struct {
+ // Disable turns off the Node.js permission model entirely.
+ Disable bool `json:"disable"`
+ AllowRead []string `json:"allowRead"`
+ AllowWrite []string `json:"allowWrite"`
+ AllowAddons []string `json:"allowAddons"`
+ AllowWorker []string `json:"allowWorker"`
+}
+
+// IsEnabled reports whether the Node.js permission model is active.
+func (p NodePermissions) IsEnabled() bool {
+ return !p.Disable
+}
+
// ToTOML converts c to TOML with [security] as the root.
func (c Config) ToTOML() string {
sec := c.ToSecurityMap()
// DecodeConfig creates a privacy Config from a given Hugo configuration.
func DecodeConfig(cfg config.Provider) (Config, error) {
sc := DefaultConfig
+ // Deep copy slices to prevent mapstructure from mutating DefaultConfig.
+ sc.Node.Permissions.AllowRead = slices.Clone(sc.Node.Permissions.AllowRead)
+ sc.Node.Permissions.AllowWrite = slices.Clone(sc.Node.Permissions.AllowWrite)
+ sc.Node.Permissions.AllowAddons = slices.Clone(sc.Node.Permissions.AllowAddons)
+ sc.Node.Permissions.AllowWorker = slices.Clone(sc.Node.Permissions.AllowWorker)
if cfg.IsSet(securityConfigKey) {
m := cfg.GetStringMap(securityConfigKey)
dec, err := mapstructure.NewDecoder(
got := DefaultConfig.ToTOML()
c.Assert(got, qt.Equals,
- "[security]\n enableInlineShortcodes = false\n\n [security.exec]\n allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^git$', '^npx$', '^postcss$', '^tailwindcss$']\n osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['.*']",
+ "[security]\n enableInlineShortcodes = false\n\n [security.exec]\n allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^git$', '^node$', '^postcss$', '^tailwindcss$']\n osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['.*']\n\n [security.node]\n [security.node.permissions]\n allowAddons = ['tailwindcss']\n allowRead = ['.']\n allowWorker = ['tailwindcss']\n allowWrite = []\n disable = false",
)
}
c.Assert(err, qt.IsNil)
c.Assert(pc, qt.Not(qt.IsNil))
c.Assert(pc.Exec.Allow.Accept("a"), qt.IsFalse)
- c.Assert(pc.Exec.Allow.Accept("npx"), qt.IsTrue)
- c.Assert(pc.Exec.Allow.Accept("Npx"), qt.IsFalse)
+ c.Assert(pc.Exec.Allow.Accept("node"), qt.IsTrue)
+ c.Assert(pc.Exec.Allow.Accept("npx"), qt.IsFalse)
c.Assert(pc.HTTP.URLs.Accept("https://example.org"), qt.IsTrue)
c.Assert(pc.HTTP.Methods.Accept("POST"), qt.IsTrue)
c.Assert(pc.Exec.OsEnv.Accept("a"), qt.IsFalse)
c.Assert(pc.Exec.OsEnv.Accept("e"), qt.IsFalse)
c.Assert(pc.Exec.OsEnv.Accept("MYSECRET"), qt.IsFalse)
+
+ c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
+ c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"."})
+ c.Assert(pc.Node.Permissions.AllowWrite, qt.DeepEquals, []string{})
+}
+
+func TestDecodeConfigNodePermissions(t *testing.T) {
+ c := qt.New(t)
+
+ c.Run("Custom paths", func(c *qt.C) {
+ c.Parallel()
+ tomlConfig := `
+[security.node.permissions]
+allowRead = ["/tmp", "."]
+allowWrite = ["."]
+`
+ cfg, err := config.FromConfigString(tomlConfig, "toml")
+ c.Assert(err, qt.IsNil)
+ pc, err := DecodeConfig(cfg)
+ c.Assert(err, qt.IsNil)
+ c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
+ c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"/tmp", "."})
+ c.Assert(pc.Node.Permissions.AllowWrite, qt.DeepEquals, []string{"."})
+ })
+
+ c.Run("Disabled", func(c *qt.C) {
+ c.Parallel()
+ tomlConfig := `
+[security.node.permissions]
+disable = true
+`
+ cfg, err := config.FromConfigString(tomlConfig, "toml")
+ c.Assert(err, qt.IsNil)
+ pc, err := DecodeConfig(cfg)
+ c.Assert(err, qt.IsNil)
+ c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsFalse)
+ })
+
+ c.Run("Wildcard", func(c *qt.C) {
+ c.Parallel()
+ tomlConfig := `
+[security.node.permissions]
+allowRead = ["*"]
+allowWrite = ["*"]
+`
+ cfg, err := config.FromConfigString(tomlConfig, "toml")
+ c.Assert(err, qt.IsNil)
+ pc, err := DecodeConfig(cfg)
+ c.Assert(err, qt.IsNil)
+ c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
+ c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"*"})
+ })
}
}
}
+ d.ExecHelper.SetNodeReadPaths(d.BaseFs.Assets.RealPaths(""))
+
if d.ContentSpec == nil {
contentSpec, err := helpers.NewContentSpec(d.Conf, d.Log, d.Content.Fs, d.ExecHelper)
if err != nil {
return dirnames
}
+// RealPaths returns the absolute paths of all mounts in this filesystem.
+func (d *SourceFilesystem) RealPaths(from string) []string {
+ var paths []string
+ for _, m := range d.mounts() {
+ p := filepath.Join(m.Meta().Filename, from)
+ if _, err := d.SourceFs.Stat(p); err == nil {
+ paths = append(paths, p)
+ }
+ }
+ return paths
+}
+
// WithBaseFs allows reuse of some potentially expensive to create parts that remain
// the same across sites/languages.
func WithBaseFs(b *BaseFs) func(*BaseFs) error {
}
}
+// TestOptWithNpmInstallGlobal enables global npm package installation in integration tests.
+// This mutates the host environment and is therefore restricted to real CI.
+func TestOptWithNpmInstallGlobal(packages ...string) TestOpt {
+ return func(c *IntegrationTestConfig) {
+ c.NeedsNpmGlobalInstall = packages
+ }
+}
+
// TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin.
func TestOptWithNFDOnDarwin() TestOpt {
return func(c *IntegrationTestConfig) {
s.H = sites
s.fs = fs
- if s.Cfg.NeedsNpmInstall {
- wd, _ := os.Getwd()
- s.Assert(os.Chdir(s.Cfg.WorkingDir), qt.IsNil)
- s.C.Cleanup(func() { os.Chdir(wd) })
+ if s.Cfg.NeedsNpmInstall || len(s.Cfg.NeedsNpmGlobalInstall) > 0 {
+ if s.Cfg.NeedsNpmInstall {
+ wd, _ := os.Getwd()
+ s.Assert(os.Chdir(s.Cfg.WorkingDir), qt.IsNil)
+ s.C.Cleanup(func() { os.Chdir(wd) })
+ }
sc := security.DefaultConfig
sc.Exec.Allow, err = security.NewWhitelist("npm")
s.Assert(err, qt.IsNil)
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)
+ if len(s.Cfg.NeedsNpmGlobalInstall) > 0 {
+ if !htesting.IsRealCI() && !htesting.IsGitHubAction() {
+ panic("NeedsNpmGlobalInstall is restricted to real CI because it performs global npm installs")
+ }
+ for _, pkg := range s.Cfg.NeedsNpmGlobalInstall {
+ command, err := ex.New("npm", "install", "-g", pkg)
+ s.Assert(err, qt.IsNil)
+ s.Assert(command.Run(), qt.IsNil)
+
+ }
+ s.C.Cleanup(func() {
+ for _, pkg := range s.Cfg.NeedsNpmGlobalInstall {
+ ex.New("npm", "uninstall", "-g", pkg)
+ }
+ })
+ } else {
+ command, err := ex.New("npm", "install")
+ s.Assert(err, qt.IsNil)
+ s.Assert(command.Run(), qt.IsNil)
+ }
}
})
// Whether to run npm install before Build.
NeedsNpmInstall bool
+ // Global npm packages to install before Build. This is used for testing the npm integration in the Hugo Pipes.
+ // Only used on CI.
+ NeedsNpmGlobalInstall []string
+
// Whether to normalize the Unicode filenames to NFD on Darwin.
NFDFormOnDarwin bool
dir = 'DIAGRAM_CACHEDIR'
[security.exec]
- allow = ['^((dart-)?sass|git|go|npx|postcss|tailwindcss|asciidoctor)$']`
+ allow = ['^((dart-)?sass|git|go|node|postcss|tailwindcss|asciidoctor)$']`
func validatePublishedSite_1(b *hugolib.IntegrationTestBuilder) {
for _, file := range []publishedContentFile{
import (
"bytes"
+ "encoding/base64"
"fmt"
"io"
- "os"
"path"
"path/filepath"
"regexp"
return
}
+func (opts Options) sourceMapEnabled() bool {
+ return opts.SourceMap != "" && opts.SourceMap != "none"
+}
+
func (opts Options) toArgs() []any {
var args []any
- // external is not a known constant on the babel command line
- // .sourceMaps must be a boolean, "inline", "both", or undefined
- switch opts.SourceMap {
- case "external":
- args = append(args, "--source-maps")
- case "inline":
+ // Always use inline source maps; Transform extracts to external file if needed.
+ if opts.sourceMapEnabled() {
args = append(args, "--source-maps=inline")
}
if opts.Minified {
}
cmdArgs = append(cmdArgs, "--filename="+ctx.SourcePath)
- // Create compile into a real temp file:
- // 1. separate stdout/stderr messages from babel (https://github.com/gohugoio/hugo/issues/8136)
- // 2. allow generation and retrieval of external source map.
- compileOutput, err := os.CreateTemp("", "compileOut-*.js")
- if err != nil {
- return err
- }
-
- cmdArgs = append(cmdArgs, "--out-file="+compileOutput.Name())
+ var outBuf bytes.Buffer
stderr := io.MultiWriter(infoW, &errBuf)
cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
- cmdArgs = append(cmdArgs, hexec.WithStdout(stderr))
+ cmdArgs = append(cmdArgs, hexec.WithStdout(&outBuf))
+ cmdArgs = append(cmdArgs, hexec.WithDir(t.rs.Cfg.BaseConfig().WorkingDir))
cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
- defer func() {
- compileOutput.Close()
- os.Remove(compileOutput.Name())
- }()
-
- // ARGA [--no-install babel --config-file /private/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/hugo-test-babel812882892/babel.config.js --source-maps --filename=js/main2.js --out-file=/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/compileOut-2237820197.js]
- // [--no-install babel --config-file /private/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/hugo-test-babel332846848/babel.config.js --filename=js/main.js --out-file=/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/compileOut-1451390834.js 0x10304ee60 0x10304ed60 0x10304f060]
cmd, err := ex.Npx(binaryName, cmdArgs...)
if err != nil {
if hexec.IsNotFound(err) {
return fmt.Errorf(errBuf.String()+": %w", err)
}
- content, err := io.ReadAll(compileOutput)
- if err != nil {
- return err
- }
+ content := outBuf.Bytes()
- mapFile := compileOutput.Name() + ".map"
- if _, err := os.Stat(mapFile); err == nil {
- defer os.Remove(mapFile)
- sourceMap, err := os.ReadFile(mapFile)
- if err != nil {
- return err
- }
- if err = ctx.PublishSourceMap(sourceMap); err != nil {
- return err
+ if t.options.SourceMap == "external" {
+ if sourceMap, stripped, ok := extractInlineSourceMap(content); ok {
+ if err = ctx.PublishSourceMap(sourceMap); err != nil {
+ return err
+ }
+ targetPath := path.Base(ctx.OutPath) + ".map"
+ content = append(stripped, ("//# sourceMappingURL=" + targetPath + "\n")...)
}
- targetPath := path.Base(ctx.OutPath) + ".map"
- re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
- content = []byte(re.ReplaceAllString(string(content), "//# sourceMappingURL="+targetPath+"\n"))
}
ctx.To.Write(content)
&babelTransformation{rs: c.rs, options: options},
)
}
+
+var inlineSourceMapRe = regexp.MustCompile(`(?m)//# sourceMappingURL=data:application/json[^,]*,([A-Za-z0-9+/=]+)\s*$`)
+
+func extractInlineSourceMap(content []byte) (sourceMap, stripped []byte, ok bool) {
+ loc := inlineSourceMapRe.FindSubmatchIndex(content)
+ if loc == nil {
+ return nil, content, false
+ }
+ decoded, err := base64.StdEncoding.DecodeString(string(content[loc[2]:loc[3]]))
+ if err != nil {
+ return nil, content, false
+ }
+ return decoded, content[:loc[0]], true
+}
disablekinds = ['taxonomy', 'term', 'page']
[security]
[security.exec]
- allow = ['^npx$', '^babel$']
+ allow = ['^node$', '^babel$']
-- layouts/home.html --
{{ $options := dict "noComments" true }}
{{ $transpiled := resources.Get "js/main.js" | babel -}}
stderr := io.MultiWriter(infow, &errBuf)
cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To))
+ cmdArgs = append(cmdArgs, hexec.WithDir(t.rs.Cfg.BaseConfig().WorkingDir))
cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
cmd, err := ex.Npx(binaryName, cmdArgs...)
b.Assert(err.Error(), qt.Contains, "a.css:4:2")
}
-func TestTransformPostCSSNotInstalledError(t *testing.T) {
- if !htesting.IsCI() {
- t.Skip("Skip long running test when running locally")
- }
-
- c := qt.New(t)
-
- _, err := hugolib.TestE(c, postCSSIntegrationTestFiles, hugolib.TestOptOsFs())
-
- ferrs := herrors.UnwrapFileErrors(err)
- c.Assert(len(ferrs), qt.Equals, 1)
- c.Assert(err.Error(), qt.Contains, `binary with name "postcss" not found using npx`)
-}
-
// #9895
func TestTransformPostCSSImportError(t *testing.T) {
if !htesting.IsCI() {
stderr := io.MultiWriter(infow, &errBuf)
cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To))
+ cmdArgs = append(cmdArgs, hexec.WithDir(workingDir))
cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(workingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
cmd, err := ex.Npx(binaryName, cmdArgs...)