From cf9c8f93ca2a2838ce378f9e36d052ac2f79e229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Tue, 9 Jun 2026 11:20:01 +0200 Subject: [PATCH] Drop symlinks in os.ReadDir, os.ReadFile, os.Stat and os.FileExists Fixes #15019 --- go.mod | 2 +- go.sum | 2 ++ hugofs/component_fs.go | 18 ++++++++++ hugofs/fs.go | 10 ++++++ hugofs/nosymlinks_fs.go | 52 ++++++++++++++++++++++++++++ testscripts/commands/no_symlinks.txt | 20 ++++++++++- tpl/os/os.go | 19 ++++++---- 7 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 hugofs/nosymlinks_fs.go diff --git a/go.mod b/go.mod index da5fd7ccd..f641c4187 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/bep/lazycache v0.8.1 github.com/bep/logg v0.4.0 github.com/bep/mclib v1.20401.20400 - github.com/bep/overlayfs v0.10.0 + github.com/bep/overlayfs v0.11.0 github.com/bep/simplecobra v0.7.0 github.com/bep/textandbinarywriter v0.1.0 github.com/bep/tmc v0.6.0 diff --git a/go.sum b/go.sum index ca322b92f..5ce45984f 100644 --- a/go.sum +++ b/go.sum @@ -174,6 +174,8 @@ github.com/bep/mclib v1.20401.20400 h1:silTOMNlNI7yHBb+HxEE0THIVFVWo/0I4SCH69Fxt github.com/bep/mclib v1.20401.20400/go.mod h1:v5Hh3EIinPn7epigP28uf9JCkZlYzBS2vEOPe2wrHzM= github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE= github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro= +github.com/bep/overlayfs v0.11.0 h1:aymHDGC0CHpvn0XvTfgpK6skCp16oMi+tdUF32l6pPs= +github.com/bep/overlayfs v0.11.0/go.mod h1:L+ggdoKm+Y7Xb4a1osd+/LOPG4qsY62snqRqJH5Mspc= github.com/bep/simplecobra v0.7.0 h1:kG8ZPwEc1o96hlIVGXcrrvwC8RornBqvMD3+pS0Z7y0= github.com/bep/simplecobra v0.7.0/go.mod h1:PDXvBWH1ZMX05DRQ25ub/C6kKUuq+jROPgjbVz8wO1g= github.com/bep/textandbinarywriter v0.1.0 h1:KXmXsRN2Uhwhm1G3e/snM8+5SPQBJrCEpIosdIBR3po= diff --git a/hugofs/component_fs.go b/hugofs/component_fs.go index e59b595c7..d5c8ca676 100644 --- a/hugofs/component_fs.go +++ b/hugofs/component_fs.go @@ -46,6 +46,7 @@ func NewComponentFs(opts ComponentFsOptions) *componentFs { var ( _ FilesystemUnwrapper = (*componentFs)(nil) _ ReadDirWithContextDir = (*componentFsDir)(nil) + _ afero.Lstater = (*componentFs)(nil) ) // componentFs is a filesystem that holds one of the Hugo components, e.g. content, layouts etc. @@ -307,6 +308,23 @@ func (fs *componentFs) Stat(name string) (os.FileInfo, error) { return fim, nil } +func (fs *componentFs) LstatIfPossible(name string) (os.FileInfo, bool, error) { + if lstater, ok := fs.Fs.(afero.Lstater); ok { + fi, b, err := lstater.LstatIfPossible(name) + if err != nil { + return nil, false, err + } + fim, ok := fs.applyMeta(fi, name) + if !ok { + return nil, false, os.ErrNotExist + } + return fim, b, nil + } + + fi, err := fs.Stat(name) + return fi, false, err +} + func (fs *componentFs) applyMeta(fi FileNameIsDir, name string) (FileMetaInfo, bool) { if runtime.GOOS == "darwin" { name = norm.NFC.String(name) diff --git a/hugofs/fs.go b/hugofs/fs.go index 734521c17..3b55d617e 100644 --- a/hugofs/fs.go +++ b/hugofs/fs.go @@ -244,6 +244,8 @@ func WrapFilesystem(container, content afero.Fs) afero.Fs { return filesystemsWrapper{Fs: container, content: content} } +var _ afero.Lstater = (*filesystemsWrapper)(nil) + type filesystemsWrapper struct { afero.Fs content afero.Fs @@ -253,6 +255,14 @@ func (w filesystemsWrapper) UnwrapFilesystem() afero.Fs { return w.content } +func (w filesystemsWrapper) LstatIfPossible(name string) (os.FileInfo, bool, error) { + if lstater, ok := w.Fs.(afero.Lstater); ok { + return lstater.LstatIfPossible(name) + } + fi, err := w.Fs.Stat(name) + return fi, false, err +} + type ReadDirWithContextDir interface { ReadDirWithContext(context context.Context, count int) ([]iofs.DirEntry, context.Context, error) } diff --git a/hugofs/nosymlinks_fs.go b/hugofs/nosymlinks_fs.go new file mode 100644 index 000000000..0d710a7b4 --- /dev/null +++ b/hugofs/nosymlinks_fs.go @@ -0,0 +1,52 @@ +// 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 hugofs + +import ( + "os" + + "github.com/spf13/afero" +) + +// NewDropSymlinksFs returns an afero.Fs wrapper that treats symlinks as non-existing files. +func NewDropSymlinksFs(base afero.Fs) *DropSymlinksFs { + return &DropSymlinksFs{base} +} + +// DropSymlinksFs is an afero.Fs wrapper that treats symlinks as non-existing files. +type DropSymlinksFs struct { + afero.Fs +} + +func (fs *DropSymlinksFs) Open(name string) (afero.File, error) { + if _, err := fs.Stat(name); err != nil { + return nil, err + } + f, err := fs.Fs.Open(name) + if err != nil { + return nil, err + } + return f, nil +} + +func (fs *DropSymlinksFs) Stat(name string) (os.FileInfo, error) { + fi, err := LstatIfPossible(fs.Fs, name) + if err != nil { + return nil, err + } + if fi.Mode()&os.ModeSymlink != 0 { + return nil, os.ErrNotExist + } + return fi, nil +} diff --git a/testscripts/commands/no_symlinks.txt b/testscripts/commands/no_symlinks.txt index 1a706f996..427927547 100644 --- a/testscripts/commands/no_symlinks.txt +++ b/testscripts/commands/no_symlinks.txt @@ -3,6 +3,7 @@ ln ./rootfile.txt ./themes/mytheme/assets/modassetsymlink.txt ln ./rootfile.txt ./themes/mytheme/static/modstaticsymlink.txt ln ./README.md ./content/pagesymlink.md +ln ./rootdir ./assets/myassets/symlinkdir hugo @@ -27,13 +28,30 @@ Read me. -- layouts/all.html -- {{ with resources.Get "modassetok.txt"}}OK {{ .Publish }}{{ else }}FAIL{{ end }} {{ with resources.Get "modassetsymlink.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} +{{ with resources.GetMatch "modassetsymlink.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} +{{ with resources.GetMatch "myassets/symlinkdir/**"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} Page: {{ .RelPermalink }}|{{ .Content }}| + +{{/* os template package. */}} +{{ $symFilePath := "content/pagesymlink.md" }} + +{{ with os.ReadDir "assets/myassets/symlinkdir" }}FAIL {{ len . }}{{ else }}OK{{ end }} +{{ with os.Stat $symFilePath }}FAIL{{ else }}OK{{ end }} +{{ with os.ReadFile $symFilePath }}FAIL{{ else }}OK{{ end }} +{{ with os.FileExists $symFilePath }}FAIL{{ else }}OK{{ end }} + -- content/pageok.md -- -- themes/mytheme/assets/modassetok.txt -- Content. -- themes/mytheme/static/modstatictok.txt -- Content. -- rootfile.txt -- -Roo Content. +Root Content. +-- assets/myassets/myfile.txt -- +My file. +-- rootdir/rootdirfile1.txt -- +Rootdirfile1 content. +-- rootdir/rootdirfile2.txt -- +Rootdirfile2 content. diff --git a/tpl/os/os.go b/tpl/os/os.go index 4c7089779..384b1bb2f 100644 --- a/tpl/os/os.go +++ b/tpl/os/os.go @@ -24,24 +24,25 @@ import ( "github.com/bep/overlayfs" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/hugofs" "github.com/spf13/afero" "github.com/spf13/cast" ) // New returns a new instance of the os-namespaced template functions. func New(d *deps.Deps) *Namespace { - var readFileFs, workFs afero.Fs + var readFileFs, workFs *hugofs.DropSymlinksFs // The docshelper script does not have or need all the dependencies set up. if d.PathSpec != nil { - readFileFs = overlayfs.New(overlayfs.Options{ + readFileFs = hugofs.NewDropSymlinksFs(overlayfs.New(overlayfs.Options{ Fss: []afero.Fs{ d.PathSpec.BaseFs.Work, d.PathSpec.BaseFs.Content.Fs, }, - }) + })) // See #9599 - workFs = d.PathSpec.BaseFs.WorkDir + workFs = hugofs.NewDropSymlinksFs(d.PathSpec.BaseFs.WorkDir) } return &Namespace{ @@ -53,8 +54,8 @@ func New(d *deps.Deps) *Namespace { // Namespace provides template functions for the "os" namespace. type Namespace struct { - readFileFs afero.Fs - workFs afero.Fs + readFileFs *hugofs.DropSymlinksFs + workFs *hugofs.DropSymlinksFs deps *deps.Deps } @@ -118,6 +119,9 @@ func (ns *Namespace) ReadDir(i any) ([]_os.FileInfo, error) { list, err := afero.ReadDir(ns.workFs, path) if err != nil { + if herrors.IsNotExist(err) { + return nil, nil + } return nil, fmt.Errorf("failed to read directory %q: %s", path, err) } @@ -156,6 +160,9 @@ func (ns *Namespace) Stat(i any) (_os.FileInfo, error) { r, err := ns.readFileFs.Stat(path) if err != nil { + if herrors.IsNotExist(err) { + return nil, nil + } return nil, err }