475 lines
16 KiB
Go
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)
|
|
}
|
|
}
|