Files
2026-07-13 11:58:46 +08:00

475 lines
16 KiB
Go

// 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)
}
}