safing-portmaster/base/database/storage/fstree/fstree.go
2024-12-06 14:47:24 +02:00

302 lines
7.6 KiB
Go

/*
Package fstree provides a dead simple file-based database storage backend.
It is primarily meant for easy testing or storing big files that can easily be accesses directly, without datastore.
*/
package fstree
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/safing/portmaster/base/database/iterator"
"github.com/safing/portmaster/base/database/query"
"github.com/safing/portmaster/base/database/record"
"github.com/safing/portmaster/base/database/storage"
"github.com/safing/portmaster/base/utils/renameio"
)
const (
defaultFileMode = os.FileMode(0o0644)
defaultDirMode = os.FileMode(0o0755)
onWindows = runtime.GOOS == "windows"
)
// FSTree database storage.
type FSTree struct {
name string
basePath string
}
func init() {
_ = storage.Register("fstree", NewFSTree)
}
// NewFSTree returns a (new) FSTree database.
func NewFSTree(name, location string) (storage.Interface, error) {
basePath, err := filepath.Abs(location)
if err != nil {
return nil, fmt.Errorf("fstree: failed to validate path %s: %w", location, err)
}
file, err := os.Stat(basePath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
err = os.MkdirAll(basePath, defaultDirMode)
if err != nil {
return nil, fmt.Errorf("fstree: failed to create directory %s: %w", basePath, err)
}
} else {
return nil, fmt.Errorf("fstree: failed to stat path %s: %w", basePath, err)
}
} else {
if !file.IsDir() {
return nil, fmt.Errorf("fstree: provided database path (%s) is a file", basePath)
}
}
return &FSTree{
name: name,
basePath: basePath,
}, nil
}
func (fst *FSTree) buildFilePath(key string, checkKeyLength bool) (string, error) {
// check key length
if checkKeyLength && len(key) < 1 {
return "", fmt.Errorf("fstree: key too short: %s", key)
}
// build filepath
dstPath := filepath.Join(fst.basePath, key) // Join also calls Clean()
if !strings.HasPrefix(dstPath, fst.basePath) {
return "", fmt.Errorf("fstree: key integrity check failed, compiled path is %s", dstPath)
}
// return
return dstPath, nil
}
// Get returns a database record.
func (fst *FSTree) Get(key string) (record.Record, error) {
dstPath, err := fst.buildFilePath(key, true)
if err != nil {
return nil, err
}
data, err := os.ReadFile(dstPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, storage.ErrNotFound
}
return nil, fmt.Errorf("fstree: failed to read file %s: %w", dstPath, err)
}
r, err := record.NewRawWrapper(fst.name, key, data)
if err != nil {
return nil, err
}
return r, nil
}
// GetMeta returns the metadata of a database record.
func (fst *FSTree) GetMeta(key string) (*record.Meta, error) {
// TODO: Replace with more performant variant.
r, err := fst.Get(key)
if err != nil {
return nil, err
}
return r.Meta(), nil
}
// Put stores a record in the database.
func (fst *FSTree) Put(r record.Record) (record.Record, error) {
dstPath, err := fst.buildFilePath(r.DatabaseKey(), true)
if err != nil {
return nil, err
}
data, err := r.MarshalRecord(r)
if err != nil {
return nil, err
}
err = writeFile(dstPath, data, defaultFileMode)
if err != nil {
// create dir and try again
err = os.MkdirAll(filepath.Dir(dstPath), defaultDirMode)
if err != nil {
return nil, fmt.Errorf("fstree: failed to create directory %s: %w", filepath.Dir(dstPath), err)
}
err = writeFile(dstPath, data, defaultFileMode)
if err != nil {
return nil, fmt.Errorf("fstree: could not write file %s: %w", dstPath, err)
}
}
return r, nil
}
// Delete deletes a record from the database.
func (fst *FSTree) Delete(key string) error {
dstPath, err := fst.buildFilePath(key, true)
if err != nil {
return err
}
// remove entry
err = os.Remove(dstPath)
if err != nil {
return fmt.Errorf("fstree: could not delete %s: %w", dstPath, err)
}
return nil
}
// Query returns a an iterator for the supplied query.
func (fst *FSTree) Query(q *query.Query, local, internal bool) (*iterator.Iterator, error) {
_, err := q.Check()
if err != nil {
return nil, fmt.Errorf("invalid query: %w", err)
}
walkPrefix, err := fst.buildFilePath(q.DatabaseKeyPrefix(), false)
if err != nil {
return nil, err
}
fileInfo, err := os.Stat(walkPrefix)
var walkRoot string
switch {
case err == nil && fileInfo.IsDir():
walkRoot = walkPrefix
case err == nil:
walkRoot = filepath.Dir(walkPrefix)
case errors.Is(err, fs.ErrNotExist):
walkRoot = filepath.Dir(walkPrefix)
default: // err != nil
return nil, fmt.Errorf("fstree: could not stat query root %s: %w", walkPrefix, err)
}
queryIter := iterator.New()
go fst.queryExecutor(walkRoot, queryIter, q, local, internal)
return queryIter, nil
}
func (fst *FSTree) queryExecutor(walkRoot string, queryIter *iterator.Iterator, q *query.Query, local, internal bool) {
err := filepath.Walk(walkRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("fstree: error in walking fs: %w", err)
}
if info.IsDir() {
// skip dir if not in scope
if !strings.HasPrefix(path, fst.basePath) {
return filepath.SkipDir
}
// continue
return nil
}
// still in scope?
if !strings.HasPrefix(path, fst.basePath) {
return nil
}
// read file
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return fmt.Errorf("fstree: failed to read file %s: %w", path, err)
}
// parse
key, err := filepath.Rel(fst.basePath, path)
if err != nil {
return fmt.Errorf("fstree: failed to extract key from filepath %s: %w", path, err)
}
r, err := record.NewRawWrapper(fst.name, key, data)
if err != nil {
return fmt.Errorf("fstree: failed to load file %s: %w", path, err)
}
if !r.Meta().CheckValidity() {
// record is not valid
return nil
}
if !r.Meta().CheckPermission(local, internal) {
// no permission to access
return nil
}
// check if matches, then send
if q.MatchesRecord(r) {
select {
case queryIter.Next <- r:
case <-queryIter.Done:
case <-time.After(1 * time.Second):
return errors.New("fstree: query buffer full, timeout")
}
}
return nil
})
queryIter.Finish(err)
}
// ReadOnly returns whether the database is read only.
func (fst *FSTree) ReadOnly() bool {
return false
}
// Injected returns whether the database is injected.
func (fst *FSTree) Injected() bool {
return false
}
// MaintainRecordStates maintains records states in the database.
func (fst *FSTree) MaintainRecordStates(ctx context.Context, purgeDeletedBefore time.Time, shadowDelete bool) error {
// TODO: implement MaintainRecordStates
return nil
}
// Shutdown shuts down the database.
func (fst *FSTree) Shutdown() error {
return nil
}
// writeFile mirrors os.WriteFile, replacing an existing file with the same
// name atomically. This is not atomic on Windows, but still an improvement.
// TODO: Replace with github.com/google/renamio.WriteFile as soon as it is fixed on Windows.
// TODO: This has become a wont-fix. Explore other options.
// This function is forked from https://github.com/google/renameio/blob/a368f9987532a68a3d676566141654a81aa8100b/writefile.go.
func writeFile(filename string, data []byte, perm os.FileMode) error {
t, err := renameio.TempFile("", filename)
if err != nil {
return err
}
defer t.Cleanup() //nolint:errcheck
// Set permissions before writing data, in case the data is sensitive.
// TODO(vladimir): to set permissions on windows we need the full path of the file.
err = t.Chmod(perm)
if err != nil {
return err
}
if _, err := t.Write(data); err != nil {
return err
}
return t.CloseAtomicallyReplace()
}