// 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", "--allow-child-process", "--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", "--allow-child-process", "--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", "--allow-child-process", "--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 cfg.Node.Permissions.AllowChildProcess = 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) } }