hugo-wasm/hugofs/rootmapping_fs.go
Bjørn Erik Pedersen 77824d704c Fix same resource file published more than once
This may still happen, though, in low memory situations or very big sites, but I'm not sure it's worth spending time on fixing that. Writing the same file more than once isn't harmful, the negative effect is the false path warning.
 We may find a way to detect that situation if this becomes a real problem.

Fixes #13164
2024-12-27 18:55:24 +01:00

855 lines
20 KiB
Go

// 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.
// 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 (
"errors"
"fmt"
iofs "io/fs"
"os"
"path"
"path/filepath"
"strings"
"sync/atomic"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/paths"
"github.com/bep/overlayfs"
"github.com/gohugoio/hugo/hugofs/files"
"github.com/gohugoio/hugo/hugofs/glob"
radix "github.com/armon/go-radix"
"github.com/spf13/afero"
)
var filepathSeparator = string(filepath.Separator)
var _ ReverseLookupProvder = (*RootMappingFs)(nil)
// NewRootMappingFs creates a new RootMappingFs on top of the provided with
// root mappings with some optional metadata about the root.
// Note that From represents a virtual root that maps to the actual filename in To.
func NewRootMappingFs(fs afero.Fs, rms ...RootMapping) (*RootMappingFs, error) {
rootMapToReal := radix.New()
realMapToRoot := radix.New()
id := fmt.Sprintf("rfs-%d", rootMappingFsCounter.Add(1))
addMapping := func(key string, rm RootMapping, to *radix.Tree) {
var mappings []RootMapping
v, found := to.Get(key)
if found {
// There may be more than one language pointing to the same root.
mappings = v.([]RootMapping)
}
mappings = append(mappings, rm)
to.Insert(key, mappings)
}
for _, rm := range rms {
(&rm).clean()
rm.FromBase = files.ResolveComponentFolder(rm.From)
if len(rm.To) < 2 {
panic(fmt.Sprintf("invalid root mapping; from/to: %s/%s", rm.From, rm.To))
}
fi, err := fs.Stat(rm.To)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
if rm.Meta == nil {
rm.Meta = NewFileMeta()
}
if rm.FromBase == "" {
panic(" rm.FromBase is empty")
}
rm.Meta.Component = rm.FromBase
rm.Meta.Module = rm.Module
rm.Meta.ModuleOrdinal = rm.ModuleOrdinal
rm.Meta.IsProject = rm.IsProject
rm.Meta.BaseDir = rm.ToBase
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 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
singleFileMeta := rm.Meta.Copy()
singleFileMeta.Name = nameFrom
rm.fiSingleFile = NewFileMetaInfo(fi, singleFileMeta)
rm.To = dirTo
rm.Meta.Rename = func(name string, toFrom bool) (string, bool) {
if toFrom {
if name == nameTo {
return nameFrom, true
}
return "", false
}
if name == nameFrom {
return nameTo, true
}
return "", false
}
nameToFilename := filepathSeparator + nameTo
rm.Meta.InclusionFilter = rm.Meta.InclusionFilter.Append(glob.NewFilenameFilterForInclusionFunc(
func(filename string) bool {
return nameToFilename == filename
},
))
// Refresh the FileInfo object.
fi, err = fs.Stat(rm.To)
if err != nil {
if herrors.IsNotExist(err) {
continue
}
return nil, err
}
}
// Extract "blog" from "content/blog"
rm.path = strings.TrimPrefix(strings.TrimPrefix(rm.From, rm.FromBase), filepathSeparator)
rm.Meta.SourceRoot = fi.(MetaProvider).Meta().Filename
meta := rm.Meta.Copy()
if !fi.IsDir() {
_, name := filepath.Split(rm.From)
meta.Name = name
}
rm.fi = NewFileMetaInfo(fi, meta)
addMapping(filepathSeparator+rm.From, rm, rootMapToReal)
rev := rm.To
if !strings.HasPrefix(rev, filepathSeparator) {
rev = filepathSeparator + rev
}
addMapping(rev, rm, realMapToRoot)
}
rfs := &RootMappingFs{
id: id,
Fs: fs,
rootMapToReal: rootMapToReal,
realMapToRoot: realMapToRoot,
}
return rfs, nil
}
func newRootMappingFsFromFromTo(
baseDir string,
fs afero.Fs,
fromTo ...string,
) (*RootMappingFs, error) {
rms := make([]RootMapping, len(fromTo)/2)
for i, j := 0, 0; j < len(fromTo); i, j = i+1, j+2 {
rms[i] = RootMapping{
From: fromTo[j],
To: fromTo[j+1],
ToBase: baseDir,
}
}
return NewRootMappingFs(fs, rms...)
}
// RootMapping describes a virtual file or directory mount.
type RootMapping struct {
From string // The virtual mount.
FromBase string // The base directory of the virtual mount.
To string // The source directory or file.
ToBase string // The base of To. May be empty if an absolute path was provided.
Module string // The module path/ID.
ModuleOrdinal int // The module ordinal starting with 0 which is the project.
IsProject bool // Whether this is a mount in the main project.
Meta *FileMeta // File metadata (lang etc.)
fi FileMetaInfo
fiSingleFile FileMetaInfo // Also set when this mounts represents a single file with a rename func.
path string // The virtual mount point, e.g. "blog".
}
type keyRootMappings struct {
key string
roots []RootMapping
}
func (rm *RootMapping) clean() {
rm.From = strings.Trim(filepath.Clean(rm.From), filepathSeparator)
rm.To = filepath.Clean(rm.To)
}
func (r RootMapping) filename(name string) string {
if name == "" {
return r.To
}
return filepath.Join(r.To, strings.TrimPrefix(name, r.From))
}
func (r RootMapping) trimFrom(name string) string {
if name == "" {
return ""
}
return strings.TrimPrefix(name, r.From)
}
var _ FilesystemUnwrapper = (*RootMappingFs)(nil)
// A RootMappingFs maps several roots into one. Note that the root of this filesystem
// is directories only, and they will be returned in Readdir and Readdirnames
// in the order given.
type RootMappingFs struct {
id string
afero.Fs
rootMapToReal *radix.Tree
realMapToRoot *radix.Tree
}
var rootMappingFsCounter atomic.Int32
func (fs *RootMappingFs) Mounts(base string) ([]FileMetaInfo, error) {
base = filepathSeparator + fs.cleanName(base)
roots := fs.getRootsWithPrefix(base)
if roots == nil {
return nil, nil
}
fss := make([]FileMetaInfo, 0, len(roots))
for _, r := range roots {
if r.fiSingleFile != nil {
// A single file mount.
fss = append(fss, r.fiSingleFile)
continue
}
bfs := NewBasePathFs(fs.Fs, r.To)
fs := bfs
if r.Meta.InclusionFilter != nil {
fs = newFilenameFilterFs(fs, r.To, r.Meta.InclusionFilter)
}
fs = decorateDirs(fs, r.Meta)
fi, err := fs.Stat("")
if err != nil {
continue
}
fss = append(fss, fi.(FileMetaInfo))
}
return fss, nil
}
func (fs *RootMappingFs) Key() string {
return fs.id
}
func (fs *RootMappingFs) UnwrapFilesystem() afero.Fs {
return fs.Fs
}
// Filter creates a copy of this filesystem with only mappings matching a filter.
func (fs RootMappingFs) Filter(f func(m RootMapping) bool) *RootMappingFs {
rootMapToReal := radix.New()
fs.rootMapToReal.Walk(func(b string, v any) bool {
rms := v.([]RootMapping)
var nrms []RootMapping
for _, rm := range rms {
if f(rm) {
nrms = append(nrms, rm)
}
}
if len(nrms) != 0 {
rootMapToReal.Insert(b, nrms)
}
return false
})
fs.rootMapToReal = rootMapToReal
return &fs
}
// Open opens the named file for reading.
func (fs *RootMappingFs) Open(name string) (afero.File, error) {
fis, err := fs.doStat(name)
if err != nil {
return nil, err
}
return fs.newUnionFile(fis...)
}
// Stat returns the os.FileInfo structure describing a given file. If there is
// an error, it will be of type *os.PathError.
// If multiple roots are found, the last one will be used.
func (fs *RootMappingFs) Stat(name string) (os.FileInfo, error) {
fis, err := fs.doStat(name)
if err != nil {
return nil, err
}
return fis[len(fis)-1], nil
}
type ComponentPath struct {
Component string
Path string
Lang string
Watch bool
}
func (c ComponentPath) ComponentPathJoined() string {
return path.Join(c.Component, c.Path)
}
type ReverseLookupProvder interface {
ReverseLookup(filename string) ([]ComponentPath, error)
ReverseLookupComponent(component, filename string) ([]ComponentPath, error)
}
// func (fs *RootMappingFs) ReverseStat(filename string) ([]FileMetaInfo, error)
func (fs *RootMappingFs) ReverseLookup(filename string) ([]ComponentPath, error) {
return fs.ReverseLookupComponent("", filename)
}
func (fs *RootMappingFs) ReverseLookupComponent(component, filename string) ([]ComponentPath, error) {
filename = fs.cleanName(filename)
key := filepathSeparator + filename
s, roots := fs.getRootsReverse(key)
if len(roots) == 0 {
return nil, nil
}
var cps []ComponentPath
base := strings.TrimPrefix(key, s)
dir, name := filepath.Split(base)
for _, first := range roots {
if component != "" && first.FromBase != component {
continue
}
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)
}
cps = append(cps, ComponentPath{
Component: first.FromBase,
Path: paths.ToSlashTrimLeading(filename),
Lang: first.Meta.Lang,
Watch: first.Meta.Watch,
})
}
return cps, nil
}
func (fs *RootMappingFs) hasPrefix(prefix string) bool {
hasPrefix := false
fs.rootMapToReal.WalkPrefix(prefix, func(b string, v any) bool {
hasPrefix = true
return true
})
return hasPrefix
}
func (fs *RootMappingFs) getRoot(key string) []RootMapping {
v, found := fs.rootMapToReal.Get(key)
if !found {
return nil
}
return v.([]RootMapping)
}
func (fs *RootMappingFs) getRoots(key string) (string, []RootMapping) {
tree := fs.rootMapToReal
levels := strings.Count(key, filepathSeparator)
seen := make(map[RootMapping]bool)
var roots []RootMapping
var s string
for {
var found bool
ss, vv, found := tree.LongestPrefix(key)
if !found || (levels < 2 && ss == key) {
break
}
for _, rm := range vv.([]RootMapping) {
if !seen[rm] {
seen[rm] = true
roots = append(roots, rm)
}
}
s = ss
// We may have more than one root for this key, so walk up.
oldKey := key
key = filepath.Dir(key)
if key == oldKey {
break
}
}
return s, roots
}
func (fs *RootMappingFs) getRootsReverse(key string) (string, []RootMapping) {
tree := fs.realMapToRoot
s, v, found := tree.LongestPrefix(key)
if !found {
return "", nil
}
return s, v.([]RootMapping)
}
func (fs *RootMappingFs) getRootsWithPrefix(prefix string) []RootMapping {
var roots []RootMapping
fs.rootMapToReal.WalkPrefix(prefix, func(b string, v any) bool {
roots = append(roots, v.([]RootMapping)...)
return false
})
return roots
}
func (fs *RootMappingFs) getAncestors(prefix string) []keyRootMappings {
var roots []keyRootMappings
fs.rootMapToReal.WalkPath(prefix, func(s string, v any) bool {
if strings.HasPrefix(prefix, s+filepathSeparator) {
roots = append(roots, keyRootMappings{
key: s,
roots: v.([]RootMapping),
})
}
return false
})
return roots
}
func (fs *RootMappingFs) newUnionFile(fis ...FileMetaInfo) (afero.File, error) {
if len(fis) == 1 {
return fis[0].Meta().Open()
}
if !fis[0].IsDir() {
// Pick the last file mount.
return fis[len(fis)-1].Meta().Open()
}
openers := make([]func() (afero.File, error), len(fis))
for i := len(fis) - 1; i >= 0; i-- {
fi := fis[i]
openers[i] = func() (afero.File, error) {
meta := fi.Meta()
f, err := meta.Open()
if err != nil {
return nil, err
}
return &rootMappingDir{DirOnlyOps: f, fs: fs, name: meta.Name, meta: meta}, nil
}
}
merge := func(lofi, bofi []iofs.DirEntry) []iofs.DirEntry {
// Ignore duplicate directory entries
for _, fi1 := range bofi {
var found bool
for _, fi2 := range lofi {
if !fi2.IsDir() {
continue
}
if fi1.Name() == fi2.Name() {
found = true
break
}
}
if !found {
lofi = append(lofi, fi1)
}
}
return lofi
}
info := func() (os.FileInfo, error) {
return fis[0], nil
}
return overlayfs.OpenDir(merge, info, openers...)
}
func (fs *RootMappingFs) cleanName(name string) string {
name = strings.Trim(filepath.Clean(name), filepathSeparator)
if name == "." {
name = ""
}
return name
}
func (rfs *RootMappingFs) collectDirEntries(prefix string) ([]iofs.DirEntry, error) {
prefix = filepathSeparator + rfs.cleanName(prefix)
var fis []iofs.DirEntry
seen := make(map[string]bool) // Prevent duplicate directories
level := strings.Count(prefix, filepathSeparator)
collectDir := func(rm RootMapping, fi FileMetaInfo) error {
f, err := fi.Meta().Open()
if err != nil {
return err
}
direntries, err := f.(iofs.ReadDirFile).ReadDir(-1)
if err != nil {
f.Close()
return err
}
for _, fi := range direntries {
meta := fi.(FileMetaInfo).Meta()
meta.Merge(rm.Meta)
if !rm.Meta.InclusionFilter.Match(strings.TrimPrefix(meta.Filename, meta.SourceRoot), fi.IsDir()) {
continue
}
if fi.IsDir() {
name := fi.Name()
if seen[name] {
continue
}
seen[name] = true
opener := func() (afero.File, error) {
return rfs.Open(filepath.Join(rm.From, name))
}
fi = newDirNameOnlyFileInfo(name, meta, opener)
} else if rm.Meta.Rename != nil {
n, ok := rm.Meta.Rename(fi.Name(), true)
if !ok {
continue
}
fi.(MetaProvider).Meta().Name = n
}
fis = append(fis, fi)
}
f.Close()
return nil
}
// First add any real files/directories.
rms := rfs.getRoot(prefix)
for _, rm := range rms {
if err := collectDir(rm, rm.fi); err != nil {
return nil, err
}
}
// Next add any file mounts inside the given directory.
prefixInside := prefix + filepathSeparator
rfs.rootMapToReal.WalkPrefix(prefixInside, func(s string, v any) bool {
if (strings.Count(s, filepathSeparator) - level) != 1 {
// This directory is not part of the current, but we
// need to include the first name part to make it
// navigable.
path := strings.TrimPrefix(s, prefixInside)
parts := strings.Split(path, filepathSeparator)
name := parts[0]
if seen[name] {
return false
}
seen[name] = true
opener := func() (afero.File, error) {
return rfs.Open(path)
}
fi := newDirNameOnlyFileInfo(name, nil, opener)
fis = append(fis, fi)
return false
}
rms := v.([]RootMapping)
for _, rm := range rms {
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
})
// Finally add any ancestor dirs with files in this directory.
ancestors := rfs.getAncestors(prefix)
for _, root := range ancestors {
subdir := strings.TrimPrefix(prefix, root.key)
for _, rm := range root.roots {
if rm.fi.IsDir() {
fi, err := rm.fi.Meta().JoinStat(subdir)
if err == nil {
if err := collectDir(rm, fi); err != nil {
return nil, err
}
}
}
}
}
return fis, nil
}
func (fs *RootMappingFs) doStat(name string) ([]FileMetaInfo, error) {
fis, err := fs.doDoStat(name)
if err != nil {
return nil, err
}
// Sanity check. Check that all is either file or directories.
var isDir, isFile bool
for _, fi := range fis {
if fi.IsDir() {
isDir = true
} else {
isFile = true
}
}
if isDir && isFile {
// For now.
return nil, os.ErrNotExist
}
return fis, nil
}
func (fs *RootMappingFs) doDoStat(name string) ([]FileMetaInfo, error) {
name = fs.cleanName(name)
key := filepathSeparator + name
roots := fs.getRoot(key)
if roots == nil {
if fs.hasPrefix(key) {
// We have directories mounted below this.
// Make it look like a directory.
return []FileMetaInfo{newDirNameOnlyFileInfo(name, nil, fs.virtualDirOpener(name))}, nil
}
// Find any real directories with this key.
_, roots := fs.getRoots(key)
if roots == nil {
return nil, &os.PathError{Op: "LStat", Path: name, Err: os.ErrNotExist}
}
var err error
var fis []FileMetaInfo
for _, rm := range roots {
var fi FileMetaInfo
fi, err = fs.statRoot(rm, name)
if err == nil {
fis = append(fis, fi)
}
}
if fis != nil {
return fis, nil
}
if err == nil {
err = &os.PathError{Op: "LStat", Path: name, Err: err}
}
return nil, err
}
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 {
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), true) {
return nil, os.ErrNotExist
}
filename = root.filename(filename)
fi, err := fs.Fs.Stat(filename)
if err != nil {
return nil, err
}
var opener func() (afero.File, error)
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)
return fim, nil
}
func (fs *RootMappingFs) virtualDirOpener(name string) func() (afero.File, error) {
return func() (afero.File, error) { return &rootMappingDir{name: name, fs: fs}, nil }
}
func (fs *RootMappingFs) realDirOpener(name string, meta *FileMeta) func() (afero.File, error) {
return func() (afero.File, error) {
f, err := fs.Fs.Open(name)
if err != nil {
return nil, err
}
return &rootMappingDir{name: name, meta: meta, fs: fs, DirOnlyOps: f}, nil
}
}
var _ iofs.ReadDirFile = (*rootMappingDir)(nil)
type rootMappingDir struct {
*noOpRegularFileOps
DirOnlyOps
fs *RootMappingFs
name string
meta *FileMeta
}
func (f *rootMappingDir) Close() error {
if f.DirOnlyOps == nil {
return nil
}
return f.DirOnlyOps.Close()
}
func (f *rootMappingDir) Name() string {
return f.name
}
func (f *rootMappingDir) ReadDir(count int) ([]iofs.DirEntry, error) {
if f.DirOnlyOps != nil {
fis, err := f.DirOnlyOps.(iofs.ReadDirFile).ReadDir(count)
if err != nil {
return nil, err
}
var result []iofs.DirEntry
for _, fi := range fis {
fim := decorateFileInfo(fi, nil, "", f.meta)
meta := fim.Meta()
if f.meta.InclusionFilter.Match(strings.TrimPrefix(meta.Filename, meta.SourceRoot), fim.IsDir()) {
result = append(result, fim)
}
}
return result, nil
}
return f.fs.collectDirEntries(f.name)
}
// Sentinel error to signal that a file is a directory.
var errIsDir = errors.New("isDir")
func (f *rootMappingDir) Stat() (iofs.FileInfo, error) {
return nil, errIsDir
}
func (f *rootMappingDir) Readdir(count int) ([]os.FileInfo, error) {
panic("not supported: use ReadDir")
}
// Note that Readdirnames preserves the order of the underlying filesystem(s),
// which is usually directory order.
func (f *rootMappingDir) Readdirnames(count int) ([]string, error) {
dirs, err := f.ReadDir(count)
if err != nil {
return nil, err
}
return dirEntriesToNames(dirs), nil
}
func dirEntriesToNames(fis []iofs.DirEntry) []string {
names := make([]string, len(fis))
for i, d := range fis {
names[i] = d.Name()
}
return names
}