Repository: gohugoio/hugo (language: go)

Issue:
Hugo panics when running with -s exampleSite in v0.123.x
### What version of Hugo are you using (`hugo version`)?

<pre>
hugo v0.123.3-a75a659f6fc0cb3a52b2b2ba666a81f79a459376+extended darwin/arm64 BuildDate=2024-02-23T17:09:20Z VendorInfo=brew
</pre>

### Does this issue reproduce with the latest release?

Yes

### Context

When upgrading Hugo from v0.122.0 to v0.123.3 I get a Hugo panic. Below the full error logs (specific to the `mod-leaflet` module):

<details>
<summary>Full logs</summary>

```bash
Start building sites … 
hugo v0.123.3-a75a659f6fc0cb3a52b2b2ba666a81f79a459376+extended darwin/arm64 BuildDate=2024-02-23T17:09:20Z VendorInfo=brew

panic: not supported [recovered]
	panic: interface conversion: string is not error: missing method Error

goroutine 25 [running]:
github.com/spf13/fsync.(*Syncer).syncRecover.func1()
	github.com/spf13/fsync@v0.10.0/fsync.go:132 +0xac
panic({0x104063b40?, 0x1044a48f0?})
	runtime/panic.go:770 +0x124
github.com/bep/overlayfs.(*Dir).Read(0x8000?, {0x104063bc0?, 0x14000749501?, 0x100966154?})
	github.com/bep/overlayfs@v0.9.1/overlayfs.go:391 +0x2c
io.copyBuffer({0x12ca25a00, 0x1400015ed00}, {0x12ca25a20, 0x1400015eee0}, {0x0, 0x0, 0x0})
	io/io.go:429 +0x18c
io.Copy(...)
	io/io.go:388
github.com/spf13/fsync.(*Syncer).sync(0x14000749d28, {0x14000a54140, 0xd}, {0x14000a54170, 0xd})
	github.com/spf13/fsync@v0.10.0/fsync.go:173 +0x588
github.com/spf13/fsync.(*Syncer).sync.func1({0x12c9c8760, 0x1400037a540})
	github.com/spf13/fsync@v0.10.0/fsync.go:199 +0xf0
github.com/spf13/fsync.withDirEntry({0x1044d39d8?, 0x1400000e030?}, {0x103eb7d28?, 0x200000003?}, 0x14000749b80)
	github.com/spf13/fsync@v0.10.0/fsync.go:343 +0x2a8
github.com/spf13/fsync.(*Syncer).sync(0x14000749d28, {0x103eb7d28, 0x1}, {0x103eb7d28, 0x1})
	github.com/spf13/fsync@v0.10.0/fsync.go:196 +0x2ac
github.com/spf13/fsync.(*Syncer).syncRecover(0x14000749d28?, {0x103eb7d28?, 0x1?}, {0x103eb7d28?, 0x1?})
	github.com/spf13/fsync@v0.10.0/fsync.go:136 +0x58
github.com/spf13/fsync.(*Syncer).Sync(0x14000749d28, {0x103eb7d28, 0x1}, {0x103eb7d28, 0x1})
	github.com/spf13/fsync@v0.10.0/fsync.go:111 +0xa0
github.com/gohugoio/hugo/commands.(*hugoBuilder).copyStaticTo(0x14000b93100, 0x14000754120)
	github.com/gohugoio/hugo/commands/hugobuilder.go:445 +0x1e4
github.com/gohugoio/hugo/commands.(*hugoBuilder).doWithPublishDirs(0x14000b93100, 0x14000749ef8)
	github.com/gohugoio/hugo/commands/hugobuilder.go:472 +0x13c
github.com/gohugoio/hugo/commands.(*hugoBuilder).copyStatic(0x140003fa960?)
	github.com/gohugoio/hugo/commands/hugobuilder.go:403 +0x34
github.com/gohugoio/hugo/commands.(*hugoBuilder).fullBuild.func2()
	github.com/gohugoio/hugo/commands/hugobuilder.go:507 +0x28
golang.org/x/sync/errgroup.(*Group).Go.func1()
	golang.org/x/sync@v0.6.0/errgroup/errgroup.go:78 +0x58
created by golang.org/x/sync/errgroup.(*Group).Go in goroutine 1
	golang.org/x/sync@v0.6.0/errgroup/errgroup.go:75 +0x98
```
</details>

### To reproduce

```bash
git clone https://github.com/gethinode/mod-leaflet.git
cd mod-leaflet
hugo mod vendor
hugo --gc --minify -s exampleSite
```

### Notes

I'm transitioning the various modules maintained by `gethinode` to take advantage of work files (see https://github.com/gethinode/hinode/pull/786), which might prevent this issue. Nevertheless, it seemed to make sense to submit this issue anyways.


Discussion / hints:
OK, I have a failing test case for this now and I will fix this. 

```toml
  [[module.imports.mounts]]
    source = "dist"
    target = "static"
  [[module.imports.mounts]]
    source = "dist/leaflet.scss"
    target = "static/leaflet.css"
```

My logic didn't handle the "double mounts" above and paniced when it tried to read the directory as a file. You can workaround this by ... removing the single file mount (and rename or copy the source file).

I have reimplemented and simplified the file mounts in Hugo, and I obviously missed some cases where the old code worked, but at least I can now understand my own code :-)

But I will fix, thanks for the report.

Thanks @bep! 

I agree the “double” mounting is a bit odd. The module is basically wrapping an existing npm package to modularize it for Hugo. I was trying to avoid an additional npm script to rename/copy an existing dist file…

Fix patch:
diff --git a/hugofs/fileinfo.go b/hugofs/fileinfo.go
index 6d6122c0c8d..60d2a38dff3 100644
--- a/hugofs/fileinfo.go
+++ b/hugofs/fileinfo.go
@@ -73,7 +73,9 @@ type FileMeta struct {
 	InclusionFilter *glob.FilenameFilter
 
 	// Rename the name part of the file (not the directory).
-	Rename func(name string, toFrom bool) string
+	// Returns the new name and a boolean indicating if the file
+	// should be included.
+	Rename func(name string, toFrom bool) (string, bool)
 }
 
 func (m *FileMeta) Copy() *FileMeta {
diff --git a/hugofs/rootmapping_fs.go b/hugofs/rootmapping_fs.go
index 9a89914beb7..04c5b4a7268 100644
--- a/hugofs/rootmapping_fs.go
+++ b/hugofs/rootmapping_fs.go
@@ -1,4 +1,4 @@
-// Copyright 2019 The Hugo Authors. All rights reserved.
+// Copyright 2024 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.
@@ -80,42 +80,36 @@ func NewRootMappingFs(fs afero.Fs, rms ...RootMapping) (*RootMappingFs, error) {
 		if !fi.IsDir() {
 			// We do allow single file mounts.
 			// However, the file system logic will be much simpler with just directories.
-			// So, convert this mount into a directory mount with a nameTo filter and renamer.
+			// So, convert this mount into a directory mount with a renamer,
+			// which will tell the caller if name should be included.
 			dirFrom, nameFrom := filepath.Split(rm.From)
 			dirTo, nameTo := filepath.Split(rm.To)
 			dirFrom, dirTo = strings.TrimSuffix(dirFrom, filepathSeparator), strings.TrimSuffix(dirTo, filepathSeparator)
 			rm.From = dirFrom
-
-			fi, err = fs.Stat(rm.To)
-			if err != nil {
-				if herrors.IsNotExist(err) {
-					continue
-				}
-				return nil, err
-			}
-
-			rm.fiSingleFile = NewFileMetaInfo(fi, rm.Meta.Copy())
+			singleFileMeta := rm.Meta.Copy()
+			singleFileMeta.Name = nameFrom
+			rm.fiSingleFile = NewFileMetaInfo(fi, singleFileMeta)
 			rm.To = dirTo
 
-			rm.Meta.Rename = func(name string, toFrom bool) string {
+			rm.Meta.Rename = func(name string, toFrom bool) (string, bool) {
 				if toFrom {
 					if name == nameTo {
-						return nameFrom
+						return nameFrom, true
 					}
-					return name
+					return "", false
 				}
 
 				if name == nameFrom {
-					return nameTo
+					return nameTo, true
 				}
 
-				return name
+				return "", false
 			}
 			nameToFilename := filepathSeparator + nameTo
 
 			rm.Meta.InclusionFilter = rm.Meta.InclusionFilter.Append(glob.NewFilenameFilterForInclusionFunc(
 				func(filename string) bool {
-					return strings.HasPrefix(nameToFilename, filename)
+					return nameToFilename == filename
 				},
 			))
 
@@ -252,13 +246,11 @@ func (fs *RootMappingFs) Mounts(base string) ([]FileMetaInfo, error) {
 
 	fss := make([]FileMetaInfo, len(roots))
 	for i, r := range roots {
-
 		if r.fiSingleFile != nil {
 			// A single file mount.
 			fss[i] = r.fiSingleFile
 			continue
 		}
-
 		bfs := NewBasePathFs(fs.Fs, r.To)
 		fs := bfs
 		if r.Meta.InclusionFilter != nil {
@@ -269,11 +261,6 @@ func (fs *RootMappingFs) Mounts(base string) ([]FileMetaInfo, error) {
 		if err != nil {
 			return nil, fmt.Errorf("RootMappingFs.Dirs: %w", err)
 		}
-
-		if !fi.IsDir() {
-			fi.(FileMetaInfo).Meta().Merge(r.Meta)
-		}
-
 		fss[i] = fi.(FileMetaInfo)
 	}
 
@@ -365,19 +352,26 @@ func (fs *RootMappingFs) ReverseLookupComponent(component, filename string, chec
 		if component != "" && first.FromBase != component {
 			continue
 		}
-		if first.Meta.Rename != nil {
-			name = first.Meta.Rename(name, true)
-		}
-
-		// Now we know that this file _could_ be in this fs.
-		filename := filepathSeparator + filepath.Join(first.path, dir, name)
 
-		if checkExists {
-			// Confirm that it exists.
-			_, err := fs.Stat(first.FromBase + filename)
-			if err != nil {
+		var filename string
+		if first.Meta.Rename != nil {
+			// Single file mount.
+			if newname, ok := first.Meta.Rename(name, true); ok {
+				filename = filepathSeparator + filepath.Join(first.path, dir, newname)
+			} else {
 				continue
 			}
+		} else {
+			// Now we know that this file _could_ be in this fs.
+			filename = filepathSeparator + filepath.Join(first.path, dir, name)
+
+			if checkExists {
+				// Confirm that it exists.
+				_, err := fs.Stat(first.FromBase + filename)
+				if err != nil {
+					continue
+				}
+			}
 		}
 
 		cps = append(cps, ComponentPath{
@@ -570,9 +564,11 @@ func (rfs *RootMappingFs) collectDirEntries(prefix string) ([]iofs.DirEntry, err
 				}
 				fi = newDirNameOnlyFileInfo(name, meta, opener)
 			} else if rm.Meta.Rename != nil {
-				if n := rm.Meta.Rename(fi.Name(), true); n != fi.Name() {
-					fi.(MetaProvider).Meta().Name = n
+				n, ok := rm.Meta.Rename(fi.Name(), true)
+				if !ok {
+					continue
 				}
+				fi.(MetaProvider).Meta().Name = n
 			}
 			fis = append(fis, fi)
 		}
@@ -617,25 +613,16 @@ func (rfs *RootMappingFs) collectDirEntries(prefix string) ([]iofs.DirEntry, err
 
 		rms := v.([]RootMapping)
 		for _, rm := range rms {
-			if !rm.fi.IsDir() {
-				// A single file mount
-				fis = append(fis, rm.fi)
-				continue
-			}
 			name := filepath.Base(rm.From)
 			if seen[name] {
 				continue
 			}
 			seen[name] = true
-
 			opener := func() (afero.File, error) {
 				return rfs.Open(rm.From)
 			}
-
 			fi := newDirNameOnlyFileInfo(name, rm.Meta, opener)
-
 			fis = append(fis, fi)
-
 		}
 
 		return false
@@ -701,48 +688,20 @@ func (fs *RootMappingFs) doStat(name string) ([]FileMetaInfo, error) {
 		return nil, err
 	}
 
-	fileCount := 0
-	var wasFiltered bool
-	for _, root := range roots {
-		meta := root.fi.Meta()
-		if !meta.InclusionFilter.Match(strings.TrimPrefix(meta.Filename, meta.SourceRoot), root.fi.IsDir()) {
-			wasFiltered = true
-			continue
-		}
-
-		if !root.fi.IsDir() {
-			fileCount++
-		}
-		if fileCount > 1 {
-			break
-		}
-	}
-
-	if fileCount == 0 {
-		if wasFiltered {
-			return nil, os.ErrNotExist
-		}
-		// Dir only.
-		return []FileMetaInfo{newDirNameOnlyFileInfo(name, roots[0].Meta, fs.virtualDirOpener(name))}, nil
-	}
-
-	if fileCount > 1 {
-		// Not supported by this filesystem.
-		return nil, fmt.Errorf("found multiple files with name %q, use .Readdir or the source filesystem directly", name)
-	}
-
-	return []FileMetaInfo{roots[0].fi}, nil
+	return []FileMetaInfo{newDirNameOnlyFileInfo(name, roots[0].Meta, fs.virtualDirOpener(name))}, nil
 }
 
 func (fs *RootMappingFs) statRoot(root RootMapping, filename string) (FileMetaInfo, error) {
 	dir, name := filepath.Split(filename)
 	if root.Meta.Rename != nil {
-		if n := root.Meta.Rename(name, false); n != name {
-			filename = filepath.Join(dir, n)
+		n, ok := root.Meta.Rename(name, false)
+		if !ok {
+			return nil, os.ErrNotExist
 		}
+		filename = filepath.Join(dir, n)
 	}
 
-	if !root.Meta.InclusionFilter.Match(root.trimFrom(filename), root.fi.IsDir()) {
+	if !root.Meta.InclusionFilter.Match(root.trimFrom(filename), true) {
 		return nil, os.ErrNotExist
 	}
 
@@ -753,24 +712,27 @@ func (fs *RootMappingFs) statRoot(root RootMapping, filename string) (FileMetaIn
 	}
 
 	var opener func() (afero.File, error)
-	if fi.IsDir() {
-		// Make sure metadata gets applied in ReadDir.
-		opener = fs.realDirOpener(filename, root.Meta)
-	} else {
-		if root.Meta.Rename != nil {
-			if n := root.Meta.Rename(fi.Name(), true); n != fi.Name() {
-				meta := fi.(MetaProvider).Meta()
-
-				meta.Name = n
-
-			}
+	if !fi.IsDir() {
+		// Open the file directly.
+		// Opens the real file directly.
+		opener = func() (afero.File, error) {
+			return fs.Fs.Open(filename)
 		}
-
+	} else if root.Meta.Rename != nil {
+		// A single file mount where we have mounted the containing directory.
+		n, ok := root.Meta.Rename(fi.Name(), true)
+		if !ok {
+			return nil, os.ErrNotExist
+		}
+		meta := fi.(MetaProvider).Meta()
+		meta.Name = n
 		// Opens the real file directly.
 		opener = func() (afero.File, error) {
 			return fs.Fs.Open(filename)
 		}
-
+	} else {
+		// Make sure metadata gets applied in ReadDir.
+		opener = fs.realDirOpener(filename, root.Meta)
 	}
 
 	fim := decorateFileInfo(fi, opener, "", root.Meta)
diff --git a/hugolib/filesystems/basefs.go b/hugolib/filesystems/basefs.go
index 5479e22662e..aa466e0eb2a 100644
--- a/hugolib/filesystems/basefs.go
+++ b/hugolib/filesystems/basefs.go
@@ -1,4 +1,4 @@
-// Copyright 2018 The Hugo Authors. All rights reserved.
+// Copyright 2024 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.


Test patch:
diff --git a/hugofs/rootmapping_fs_test.go b/hugofs/rootmapping_fs_test.go
index 982e6dfafaf..b1ef102d36e 100644
--- a/hugofs/rootmapping_fs_test.go
+++ b/hugofs/rootmapping_fs_test.go
@@ -280,6 +280,11 @@ func TestRootMappingFsMount(t *testing.T) {
 	c.Assert(err, qt.IsNil)
 	c.Assert(cps, qt.DeepEquals, []ComponentPath{
 		{Component: "content", Path: "singles/p1.md", Lang: "no"},
+	})
+
+	cps, err = rfs.ReverseLookup(filepath.FromSlash("singlefiles/sv.txt"), true)
+	c.Assert(err, qt.IsNil)
+	c.Assert(cps, qt.DeepEquals, []ComponentPath{
 		{Component: "content", Path: "singles/p1.md", Lang: "sv"},
 	})
 
diff --git a/hugolib/filesystems/basefs_test.go b/hugolib/filesystems/basefs_test.go
index f50bdb09f02..4fdeba7655a 100644
--- a/hugolib/filesystems/basefs_test.go
+++ b/hugolib/filesystems/basefs_test.go
@@ -234,6 +234,7 @@ foo
 	b := hugolib.Test(t, files)
 	bfs := b.H.BaseFs
 	watchFilenames := bfs.WatchFilenames()
+	//   []string{"/hugo_stats.json", "/content", "/content2", "/themes/t1/layouts", "/themes/t1/layouts/_default", "/themes/t1/static"}
 	b.Assert(watchFilenames, qt.HasLen, 6)
 }
 
@@ -543,6 +544,30 @@ files/f2.txt false
 `)
 }
 
+func TestMountIssue12141(t *testing.T) {
+	files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term"]
+[module]
+[[module.mounts]]
+source = "myfiles"
+target = "static"
+[[module.mounts]]
+source = "myfiles/f1.txt"
+target = "static/f2.txt"
+-- myfiles/f1.txt --
+f1
+`
+	b := hugolib.Test(t, files)
+	fs := b.H.BaseFs.StaticFs("")
+
+	b.AssertFs(fs, `
+. true
+f1.txt false
+f2.txt false
+`)
+}
+
 func checkFileCount(fs afero.Fs, dirname string, c *qt.C, expected int) {
 	c.Helper()
 	count, names, err := countFilesAndGetFilenames(fs, dirname)
diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go
index acbc093baa2..642bac7abf1 100644
--- a/hugolib/integrationtest_builder.go
+++ b/hugolib/integrationtest_builder.go
@@ -282,7 +282,7 @@ func (s *IntegrationTestBuilder) AssertPublishDir(matches ...string) {
 func (s *IntegrationTestBuilder) AssertFs(fs afero.Fs, matches ...string) {
 	s.Helper()
 	var buff bytes.Buffer
-	helpers.PrintFs(fs, "", &buff)
+	s.Assert(s.printAndCheckFs(fs, "", &buff), qt.IsNil)
 	printFsLines := strings.Split(buff.String(), "\n")
 	sort.Strings(printFsLines)
 	content := strings.TrimSpace((strings.Join(printFsLines, "\n")))
@@ -305,6 +305,34 @@ func (s *IntegrationTestBuilder) AssertFs(fs afero.Fs, matches ...string) {
 	}
 }
 
+func (s *IntegrationTestBuilder) printAndCheckFs(fs afero.Fs, path string, w io.Writer) error {
+	if fs == nil {
+		return nil
+	}
+
+	return afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return fmt.Errorf("error: path %q: %s", path, err)
+		}
+		path = filepath.ToSlash(path)
+		if path == "" {
+			path = "."
+		}
+		if !info.IsDir() {
+			f, err := fs.Open(path)
+			if err != nil {
+				return fmt.Errorf("error: path %q: %s", path, err)
+			}
+			defer f.Close()
+			// This will panic if the file is a directory.
+			var buf [1]byte
+			io.ReadFull(f, buf[:])
+		}
+		fmt.Fprintln(w, path, info.IsDir())
+		return nil
+	})
+}
+
 func (s *IntegrationTestBuilder) AssertFileExists(filename string, b bool) {
 	checker := qt.IsNil
 	if !b {