Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,76 @@ http.Handle("/static/", http.FileServer(http.FS(FS)))

Of course, instead of an `embed.FS`, you can also use any other `fs.FS` implementation, such as `os.DirFS`, etc.

### Using `go generate`
### Using `go generate` with `hashets.WrapPrecomputedFS`

> **This method is for you, if:**
>
> * 📏 You have larger assets and need lightning fast startup times
> * 🕑 You know your assets at compile time
> * 🕵 You need cache busting during development and not just in production

`hashets.WrapPrecomputedFS` is similar to `hashets.WrapFS` except it uses
a precomputed `hashets.Map` for the provided filesystem, which ideally is
generated using `go generate` for the provided filesystem during compile time.
This saves the overhead of calculating the file hashes during startup.

Add a `static.go` to your `static` directory:

```
static
├── assets
│ └── file_to_hash.ext
└── static.go
```

```go
package static

import (
"embed"

"github.com/mavolin/hashets/hashets"
)

//go:embed assets/*
var assets embed.FS

//go:generate hashets -map-only -o . assets

var (
// FileNames is defined in hashets_map.go, which is generated
// by `hashets` during code generation
FS = hashets.WrapPrecomputedFS(assets, FileNames)
)
```

`FS` can now be used exactly the same as in the previous example. Using this
method does not offer the same guarantees regarding integrity as computing
the hashes at runtime, as there is no integrity check for the hashes and
their associated files. This race condition is generally known as
[time-of-check/time-of-use][TOCTOU]. The following shell replay demonstrates
the problem:

```bash
go generate ./... # this will compute the FileNames map variable
date > static/assets/file_to_hash.ext
date > static/assets/new_file.ext
go install .
```

The filesystem `assets` embedded into the binary will now have one additional
file the `FileNames` map does not know about in addition to the existing file
(`file_to_hash.ext`) having changed after computing its hash.

Using this approach is only recommended if you have tight control over the
build pipeline or simply do not care about the cache busting quality of
the application. The example below somewhat mitigates that risk, providing
a clearer distinction between already processed files and their originals, at
the cost of file duplication.

[TOCTOU]: https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use

### Using `go generate` with a separate "hashed" directory

> **This method is for you, if:**
>
Expand Down
14 changes: 13 additions & 1 deletion hashets/fs_wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ func WrapFS(filesys fs.FS, o Options) (*FSWrapper, Map, error) {
return nil, nil, err
}

return WrapPrecomputedFS(filesys, m), m, nil
}

// WrapPrecomputedFS serves file requests for hashed filenames
// from the given filesystem using the provided lookup map.
// Failed lookups will pass the original request to the
// underlying filesystem unaltered.
// No verification of the retrieved file is performed against the
// initial lookup, making this filesystem implementation prone to
// Time-of-check/Time-of-use race conditions. Use it purely for
// cachebusting reasons and not security related tasks.
func WrapPrecomputedFS(filesys fs.FS, m Map) *FSWrapper {
reverseMap := make(map[string]string, len(m))
for k, v := range m {
reverseMap[v] = k
Expand All @@ -40,7 +52,7 @@ func WrapFS(filesys fs.FS, o Options) (*FSWrapper, Map, error) {
return &FSWrapper{
filesys: filesys,
reverseMap: reverseMap,
}, m, nil
}
}

// Open returns the file represented by the passed hashed name.
Expand Down