From 134674f00df2c2c0db24f0674de2263298d33eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Mon, 15 Jun 2026 12:37:52 +0200 Subject: [PATCH] Continue resolving on ERR_ACCESS_DENIED in Node's resolver And then rethrow the ERR_ACCESS_DENIED if we cannot recover. There's more details in #15041, but this error has been seen on Netlify with the CJS because of how Netlify has their node_modules cache folder set up. Fixes #15041 --- common/hexec/esmloader.mjs | 13 ++- .../cssjs/postcss_integration_test.go | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/common/hexec/esmloader.mjs b/common/hexec/esmloader.mjs index 356a23ed8..725a434c1 100644 --- a/common/hexec/esmloader.mjs +++ b/common/hexec/esmloader.mjs @@ -9,7 +9,16 @@ // This hook makes the ESM resolver fall back to NODE_PATH for bare // specifiers when Node's normal resolution fails. It is a no-op for // relative/absolute paths and URL-scheme specifiers, and it never fires -// unless Node would itself have thrown ERR_MODULE_NOT_FOUND. +// unless Node would itself have thrown ERR_MODULE_NOT_FOUND or +// ERR_ACCESS_DENIED. +// +// ERR_ACCESS_DENIED is handled because Node's resolver walks up the +// directory tree looking for node_modules. Under the permission model that +// walk can hit a node_modules outside the allow-list (e.g. Netlify stores +// its node_modules cache in the same tree as the Hugo file cache), aborting +// resolution even though the package is reachable via NODE_PATH. If the +// NODE_PATH fallback also fails we re-throw the original error so the +// access-denied resource is still reported. // // Uses the synchronous registerHooks API so it runs on the main thread and // does not require --allow-worker under the Node permission model. @@ -38,7 +47,7 @@ registerHooks({ try { return nextResolve(specifier, context); } catch (err) { - if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err; + if (err?.code !== 'ERR_MODULE_NOT_FOUND' && err?.code !== 'ERR_ACCESS_DENIED') throw err; if (!isBareSpecifier(specifier)) throw err; for (const r of resolvers) { try { diff --git a/resources/resource_transformers/cssjs/postcss_integration_test.go b/resources/resource_transformers/cssjs/postcss_integration_test.go index 968c159c1..11ebbf4eb 100644 --- a/resources/resource_transformers/cssjs/postcss_integration_test.go +++ b/resources/resource_transformers/cssjs/postcss_integration_test.go @@ -285,3 +285,83 @@ export default { plugins: [postcssImport()] }; "RelPermalink: /css/styles.css|HasBody: true|", ) } + +// Netlify stores its node_modules cache in the same tree as the Hugo file +// cache, so Node's resolver can walk up from a module's postcss.config.js and +// hit a node_modules outside the permission allow-list, aborting with +// ERR_ACCESS_DENIED instead of falling through to NODE_PATH. The restricted +// postcss-import below (an ancestor of the external module, never installed by +// us) forces that walk to fail; the build must still succeed by resolving the +// real postcss-import via NODE_PATH. +// +// See issue 15041. +func TestTransformPostCSSESMConfigAccessDenied(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + c := qt.New(t) + // Use htesting.CreateTempDir to get canonical paths on macOS + // (/private/var/...); Node's --permission model rejects the symlinked + // /var/folders/... form when crossing the project boundary. + rootDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") + c.Assert(err, qt.IsNil) + c.Cleanup(clean) + + projectDir := filepath.Join(rootDir, "project") + moduleDir := filepath.Join(rootDir, "external-module") + c.Assert(os.MkdirAll(projectDir, 0o755), qt.IsNil) + c.Assert(os.MkdirAll(moduleDir, 0o755), qt.IsNil) + + files := ` +-- hugo.toml -- +disableKinds = ['taxonomy', 'term', 'page'] +baseURL = "https://example.com" +[[module.imports]] +path = "github.com/bep/hugo-mod-nop" +-- assets/css/styles.css -- +body { color: red } +-- layouts/home.html -- +{{ $styles := resources.Get "css/styles.css" | css.PostCSS }} +RelPermalink: {{ $styles.RelPermalink }}|HasBody: {{ in $styles.Content "color:" }}| +-- content/_index.md -- +--- +title: home +--- +-- package.json -- +{ + "devDependencies": { + "postcss-cli": "11.0.0", + "postcss-import": "16.0.0" + } +} +-- go.mod -- +module github.com/example/project + +go 1.20 + +replace github.com/bep/hugo-mod-nop => ../external-module +-- ../node_modules/postcss-import/package.json -- +{ "name": "postcss-import", "version": "0.0.0-RESTRICTED", "main": "index.js" } +-- ../external-module/go.mod -- +module github.com/bep/hugo-mod-nop + +go 1.20 +-- ../external-module/postcss.config.js -- +import postcssImport from "postcss-import"; +export default { plugins: [postcssImport()] }; + +` + + b := hugolib.Test(c, files, + hugolib.TestOptWithConfig(func(cfg *hugolib.IntegrationTestConfig) { + cfg.WorkingDir = projectDir + cfg.NeedsOsFS = true + cfg.NeedsNpmInstall = true + }), + ) + + b.AssertFileContent("public/index.html", + "RelPermalink: /css/styles.css|HasBody: true|", + ) +}