diff --git a/web/fup/fupstatic/hashfs.go b/web/fup/fupstatic/hashfs.go new file mode 100644 index 0000000000..42a250766d --- /dev/null +++ b/web/fup/fupstatic/hashfs.go @@ -0,0 +1,172 @@ +package fupstatic + +import ( + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "path" +) + +// adaptError adapts an error from an underlying filesystem. +func adaptError(err error, name string) error { + var pErr *fs.PathError + if errors.As(err, &pErr) { + pErr.Path = name + return pErr + } + return err +} + +type staticFS struct { + fs fs.ReadFileFS + + // We need: + // original name -> new name + // new name -> original name + toHashName map[string]string + toPlainName map[string]string +} + +type staticFSFileInfo struct { + fs.FileInfo + newName string +} + +func (fi staticFSFileInfo) Name() string { return path.Base(fi.newName) } + +type staticFSDirEntry struct { + fs.DirEntry + newName string +} + +func (dent staticFSDirEntry) Name() string { return path.Base(dent.newName) } +func (dent staticFSDirEntry) Info() (fs.FileInfo, error) { + fi, err := dent.DirEntry.Info() + if err != nil { + return nil, err + } + return staticFSFileInfo{fi, dent.newName}, nil +} + +type staticFSFile struct { + fs.File + newName string +} + +func (f *staticFSFile) Stat() (fs.FileInfo, error) { + fi, err := f.File.Stat() + if err != nil { + return nil, err + } + return staticFSFileInfo{fi, f.newName}, nil +} + +type staticFSDirFile struct { + fs.ReadDirFile + basePath string + s *staticFS +} + +func (f staticFSDirFile) ReadDir(n int) ([]fs.DirEntry, error) { + dents, err := f.ReadDirFile.ReadDir(n) + if err != nil { + return nil, err + } + for n, dent := range dents { + fpath := path.Join(f.basePath, dent.Name()) + if hashName, ok := f.s.toHashName[fpath]; ok { + dents[n] = staticFSDirEntry{dent, hashName} + } + } + return dents, nil +} + +// build computes the hashes needed to serve files. +func (s *staticFS) build() error { + toHashName := make(map[string]string) + toPlainName := make(map[string]string) + if err := fs.WalkDir(s.fs, ".", func(fpath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + // We don't care about directories. + return nil + } + + // Compute the hash of this file... + bs, err := s.fs.ReadFile(fpath) + if err != nil { + return fmt.Errorf("reading %q: %w", fpath, err) + } + hexDigest := hex.EncodeToString(sha512.New().Sum(bs)[:6]) + + // and fix it up in the filename. + oldName := fpath + ext := path.Ext(oldName) + var newName string + if len(ext) == 0 { + newName = fmt.Sprintf("%s.%s", oldName, hexDigest) + } else { + newName = fmt.Sprintf("%s.%s%s", oldName[:len(oldName)-len(ext)], hexDigest, ext) + } + toHashName[oldName] = newName + toPlainName[newName] = oldName + + return nil + }); err != nil { + return err + } + s.toHashName = toHashName + s.toPlainName = toPlainName + return nil +} + +// LookupHashedName looks up the filename for the given original name. +func (s *staticFS) LookupHashedName(name string) (string, bool) { + n, ok := s.toHashName[name] + return n, ok +} + +// Open opens the named file. +func (s *staticFS) Open(name string) (fs.File, error) { + fn, ok := s.toPlainName[name] + if !ok { + // Try opening it as a plain name. + fn = name + } + f, err := s.fs.Open(fn) + if err != nil { + return nil, adaptError(err, name) + } + if rdf, ok := f.(fs.ReadDirFile); ok { + return &staticFSDirFile{rdf, name, s}, nil + } + return &staticFSFile{f, name}, nil +} + +// ReadFile provides an optimised ReadFile implementation. +func (s *staticFS) ReadFile(name string) ([]byte, error) { + fn, ok := s.toPlainName[name] + if !ok { + // Try opening it as a plain name. + fn = name + } + f, err := s.fs.ReadFile(fn) + if err != nil { + return nil, adaptError(err, name) + } + return f, nil +} + +func newStaticFS(fs fs.ReadFileFS) *staticFS { + s := &staticFS{ + fs: fs, + } + if err := s.build(); err != nil { + panic(err) + } + return s +} diff --git a/web/fup/fupstatic/hashfs_test.go b/web/fup/fupstatic/hashfs_test.go new file mode 100644 index 0000000000..5474d6f404 --- /dev/null +++ b/web/fup/fupstatic/hashfs_test.go @@ -0,0 +1,43 @@ +package fupstatic + +import ( + "testing" + "testing/fstest" +) + +func TestHashingFS(t *testing.T) { + baseFS := fstest.MapFS{ + "foo": &fstest.MapFile{ + Data: []byte("hello, world"), + }, + "bar/bar.txt": &fstest.MapFile{ + Data: []byte("foo bar baz"), + }, + } + + f := newStaticFS(baseFS) + + tcs := []struct { + origName string + newName string + wantContent string + }{ + {"foo", "foo.68656c6c6f2c", "hello, world"}, + {"bar/bar.txt", "bar/bar.666f6f206261.txt", "foo bar baz"}, + } + + for _, tc := range tcs { + t.Run(tc.origName, func(t *testing.T) { + n, ok := f.LookupHashedName(tc.origName) + if !ok { + t.Errorf("LookupHashedName returned false") + } else if n != tc.newName { + t.Errorf("LookupHashedName returned %q; want %q", n, tc.newName) + } + + if err := fstest.TestFS(f, tc.newName); err != nil { + t.Fatal(err) + } + }) + } +}