Luke Granger-Brown
5846385513
We need to depend on its API for fuphttp, so it's better if it's a separate package to avoid embedding things we don't need. In general it's probably a good idea to separate the logic from the embedded content...
176 lines
3.6 KiB
Go
176 lines
3.6 KiB
Go
// SPDX-FileCopyrightText: 2021 Luke Granger-Brown <depot@lukegb.com>
|
|
//
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package hashfs
|
|
|
|
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 FS 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 *FS
|
|
}
|
|
|
|
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 *FS) 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 *FS) LookupHashedName(name string) (string, bool) {
|
|
n, ok := s.toHashName[name]
|
|
return n, ok
|
|
}
|
|
|
|
// Open opens the named file.
|
|
func (s *FS) 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 *FS) 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 New(fs fs.ReadFileFS) *FS {
|
|
s := &FS{
|
|
fs: fs,
|
|
}
|
|
if err := s.build(); err != nil {
|
|
panic(err)
|
|
}
|
|
return s
|
|
}
|