Files
brianmcgee e3a7790e57
buildbot/nix-eval Build done.
PR Size Review Check / pr-size-review-gate (pull_request) Successful in 24s
sizelint / sizelint (pull_request) Successful in 2m12s
buildbot/nix-build Build done.
gitea-mq Already up to date with target branch
fix breaks in gofumpt and golangci-lint from updating nixpkgs
2026-07-07 16:32:14 +01:00

681 lines
18 KiB
Go

package state
import (
"errors"
"fmt"
"io"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"time"
"git.clan.lol/clan/data-mesher/pkg/config"
"git.clan.lol/clan/data-mesher/pkg/crypto"
"git.clan.lol/clan/data-mesher/pkg/model"
bolt "go.etcd.io/bbolt"
)
const (
DirTemp = "temp"
DirFiles = "files"
// MaxClockSkew is the maximum amount a signature's SignedAt may exceed the current wall-clock time.
// This prevents an authorized signer from pinning a file with a far-future timestamp that honest signers can
// never supersede.
MaxClockSkew = 5 * time.Minute
)
var (
ErrFileNotFound = errors.New("file not found")
ErrTombstoneFileExists = errors.New("tombstone file exists")
ErrSignedAtFuture = errors.New("signature timestamp too far in the future")
)
type Files struct {
cfg *config.Config
tmpDir string
filesDir string
signatures *Signatures
}
func NewFiles(cfg *config.Config) (*Files, error) {
// ensure the files directory exists
filesDir := path.Join(cfg.StateDirectory, DirFiles)
if err := os.MkdirAll(filesDir, 0o750); err != nil {
return nil, fmt.Errorf("failed to create the files directory: %w", err)
}
// clean up any partial downloads from previous runs by recreating the temp directory
tmpDir := path.Join(cfg.StateDirectory, DirTemp)
if err := recreateTempDir(tmpDir); err != nil {
return nil, err
}
// create a new signatures store
signatures, err := NewSignatures(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create signatures store: %w", err)
}
// create the file store
f := &Files{
cfg: cfg,
filesDir: filesDir,
tmpDir: tmpDir,
signatures: signatures,
}
// ensure top-level network directories exist for all configured networks
for _, network := range cfg.NetworksByID {
dirPath := f.NetworkPath(network)
if err = os.MkdirAll(dirPath, 0o750); err != nil {
return nil, fmt.Errorf("failed to create network directory: %w", err)
}
// create a symlink based on the network's name
symPath := path.Join(f.filesDir, network.Name)
// remove the existing symlink, if it exists
err = os.Remove(symPath)
if !os.IsNotExist(err) && err != nil {
return nil, fmt.Errorf("failed to remove existing symlink: %w", err)
}
// create the symlink
if err = os.Symlink(dirPath, symPath); err != nil {
return nil, fmt.Errorf("failed to create symlink: %w", err)
}
// create plugin subdirectories
for _, dir := range cfg.PluginDirectories {
pluginPath := path.Join(dirPath, dir)
if err = os.MkdirAll(pluginPath, 0o750); err != nil {
return nil, fmt.Errorf("failed to create plugin directory %s: %w", dir, err)
}
}
}
// perform an integrity check before returning
if err = checkIntegrity(f); err != nil {
return nil, fmt.Errorf("integrity check failed: %w", err)
}
return f, nil
}
func (f *Files) BeginTx(writable bool) (*bolt.Tx, error) {
return f.signatures.BeginTx(writable)
}
func (f *Files) NetworkPath(network *config.Network) string {
return path.Join(f.filesDir, network.ID.URLEncoded())
}
// FilePath returns the on-disk path for a file, partitioned by network ID.
// It returns an error if the resolved path escapes the network directory.
func (f *Files) FilePath(network *config.Network, name string) (string, error) {
base, err := filepath.Abs(f.NetworkPath(network))
if err != nil {
return "", fmt.Errorf("failed to resolve network path: %w", err)
}
clean := filepath.Clean(filepath.Join(base, name))
if !strings.HasPrefix(clean, path.Join(base)) && clean != base {
return "", fmt.Errorf("path traversal detected: name %q resolves outside network directory", name)
}
return clean, nil
}
func (f *Files) ListSignatures(tx *bolt.Tx) (SignatureReader, error) {
r, err := f.signatures.List(tx)
if err != nil {
return nil, fmt.Errorf("failed to list signatures: %w", err)
}
return r, nil
}
func (f *Files) Get(networkID *crypto.PublicKey, name string) (io.Reader, *model.Signature, error) {
// look up network config
network, ok := f.cfg.NetworksByID[networkID.String()]
if !ok {
return nil, nil, ErrNetworkNotConfigured
}
// start a read-only transaction
tx, err := f.signatures.BeginTx(false)
if err != nil {
return nil, nil, fmt.Errorf("failed to begin transaction: %w", err)
}
// ensure it gets released when we're done
defer func() {
_ = tx.Rollback()
}()
// check for a signature first
var signature model.Signature
err = f.signatures.Get(tx, networkID, name, &signature)
// we treat a signature not found as a file not found
// tombstones are also treated as file not found
if errors.Is(err, ErrSignatureNotFound) || errors.Is(err, ErrNetworkNotFound) ||
signature.Type == model.SignatureTypeTombstone {
return nil, nil, ErrFileNotFound
} else if err != nil {
return nil, nil, fmt.Errorf("failed to get signature: %w", err)
}
// expired files are also treated as file not found. The on-disk file may
// still exist briefly until the sweeper runs; we hide it from readers
// immediately so behaviour is consistent across the cluster.
if signature.Expired(f.cfg.Now()) {
return nil, nil, ErrFileNotFound
}
// try to open a reader for the file
filePath, err := f.FilePath(network, name)
if err != nil {
return nil, nil, err
}
reader, err := os.Open(filePath) //nolint:gosec // filePath is constructed from validated network dir and name
if errors.Is(err, os.ErrNotExist) {
// todo we are in a corrupt state, log it, maybe clean up the signature entry?
return nil, nil, ErrFileNotFound
} else if err != nil {
return nil, nil, fmt.Errorf("failed to open file: %w", err)
}
return reader, &signature, nil
}
// GetSignature retrieves the signature for a file, including tombstones.
// Use this when you need to compare timestamps including deleted files.
func (f *Files) GetSignature(network *crypto.PublicKey, name string, sig *model.Signature) error {
// start a read-only transaction
tx, err := f.signatures.db.Begin(false)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
// ensure it gets released when we're done
defer func() {
_ = tx.Rollback()
}()
return f.signatures.Get(tx, network, name, sig)
}
func (f *Files) Put(sig *model.Signature, r io.Reader) (bool, error) {
// look up network config
network, ok := f.cfg.NetworksByID[sig.NetworkID.String()]
if !ok {
return false, fmt.Errorf("network %s is not configured", sig.NetworkID)
}
// validate the file name to prevent path traversal
if err := model.ValidateName(sig.Name); err != nil {
return false, fmt.Errorf("rejecting file with invalid name: %w", err)
}
// reject signatures with timestamps too far in the future
if sig.SignedAt.After(time.Now().Add(MaxClockSkew)) {
return false, fmt.Errorf("%w: %s", ErrSignedAtFuture, sig.SignedAt)
}
// tombstones should use PutTombstone instead
if sig.Type != model.SignatureTypeFile {
return f.PutTombstone(sig)
}
// create a writer for the signature
sw := model.SignatureWriter{
Type: sig.Type,
NetworkID: sig.NetworkID,
Name: sig.Name,
SignedAt: sig.SignedAt,
ValidFor: sig.ValidFor,
}
// create a temp file for the content
// we replace any path separators to keep the temp directory flat
tempName := path.Join(
f.tmpDir,
fmt.Sprintf(
"%s.%d",
strings.ReplaceAll(sig.Name, "/", "_"),
sig.SignedAt.UnixNano(),
),
)
//nolint:gosec // tempName is constructed from validated filesDir and sig.Name
file, err := os.OpenFile(tempName, os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return false, fmt.Errorf("failed to create temporary file: %w", err)
}
defer func() {
// ensure the file was closed when we're done
_ = file.Close()
// check if the temp file still exists (failed put)
_, err := os.Stat(tempName)
if errors.Is(err, os.ErrNotExist) {
return
}
// if it does, remove it
if err = os.Remove(tempName); err != nil {
slog.Error("failed to remove temporary file", slog.String("name", tempName), slog.Any("err", err))
}
}()
// create a tee reader so we can verify the signature and write to the temp file at the same time
tr := io.TeeReader(r, &sw)
// copy to the temporary file
if _, err := io.Copy(file, tr); err != nil {
return false, fmt.Errorf("failed to copy content to temp file: %w", err)
}
if err = file.Close(); err != nil {
return false, fmt.Errorf("failed to close temp file: %w", err)
}
// Set the file's mtime to match the signature timestamp.
// This will make it easy for plugins to determine how old a file is without having to interact with the
// signature database.
if err = os.Chtimes(tempName, sig.SignedAt, sig.SignedAt); err != nil {
return false, fmt.Errorf("failed to set temp file mtime: %w", err)
}
// change permissions to readonly
//nolint:gosec
if err = os.Chmod(tempName, 0o644); err != nil {
return false, fmt.Errorf("failed to set temp file permissions: %w", err)
}
// check the signature
if err = sw.Close(); err != nil {
return false, fmt.Errorf("failed to close signature writer: %w", err)
}
// verify actual size matches declared size for file signatures
if sig.FileSize > 0 && sw.Size() != sig.FileSize {
return false, fmt.Errorf(
"%w: file size mismatch (got %d, expected %d)",
model.ErrSignatureNotValid, sw.Size(), sig.FileSize,
)
}
valid, err := sw.Verify(sig.SignedBy, sig.Signature, sig.FileSize)
if err != nil {
return false, fmt.Errorf("failed to verify signature: %w", err)
}
if !valid {
return false, model.ErrSignatureNotValid
}
var updated bool
// start a write transaction
tx, err := f.signatures.BeginTx(true)
if err != nil {
return false, fmt.Errorf("failed to begin transaction: %w", err)
}
// ensure it gets released when we're done
defer func() {
_ = tx.Rollback()
}()
// try to write the signature first
updated, err = f.signatures.PutIfLater(tx, sig)
if err != nil {
return false, fmt.Errorf("failed to put signature: %w", err)
}
// if the signature was not updated, we can skip moving the file
if !updated {
return false, nil
}
// otherwise, move the temp file to the final location in the files directory
finalPath, err := f.FilePath(network, sig.Name)
if err != nil {
return false, err
}
// ensure any subdirectories are created since sig.Name can contain path separators
dir := path.Dir(finalPath)
if err = os.MkdirAll(dir, 0o750); err != nil {
return true, fmt.Errorf("failed to create directory: %w", err)
}
if err = os.Rename(tempName, finalPath); err != nil {
return false, fmt.Errorf("failed to rename temporary file: %w", err)
}
// commit the transaction
if err = tx.Commit(); err != nil {
return false, fmt.Errorf("failed to commit transaction: %w", err)
}
return true, nil
}
// PutTombstone stores a tombstone (deletion record) and removes the file from disk if it exists.
func (f *Files) PutTombstone(sig *model.Signature) (bool, error) {
// look up network config
network, ok := f.cfg.NetworksByID[sig.NetworkID.String()]
if !ok {
return false, fmt.Errorf("network %s is not configured", sig.NetworkID)
}
// validate the file name to prevent path traversal
if err := model.ValidateName(sig.Name); err != nil {
return false, fmt.Errorf("rejecting tombstone with invalid name: %w", err)
}
// reject signatures with timestamps too far in the future
if sig.SignedAt.After(time.Now().Add(MaxClockSkew)) {
return false, fmt.Errorf("%w: %s", ErrSignedAtFuture, sig.SignedAt)
}
if sig.Type != model.SignatureTypeTombstone {
return false, errors.New("signature is not a tombstone")
}
// verify the tombstone signature
if err := sig.Verify(nil); err != nil {
return false, fmt.Errorf("tombstone verification failed: %w", err)
}
// start a new transaction
tx, err := f.signatures.BeginTx(true)
if err != nil {
return false, fmt.Errorf("failed to begin transaction: %w", err)
}
// ensure it gets released when we're done
defer func() {
_ = tx.Rollback()
}()
var updated bool
updated, err = f.signatures.PutIfLater(tx, sig)
if err != nil {
return false, fmt.Errorf("failed to put tombstone: %w", err)
}
// if the tombstone was not written (older than existing), skip file deletion
if !updated {
return updated, nil
}
// otherwise remove the file from disk, if it exists
filePath, err := f.FilePath(network, sig.Name)
if err != nil {
return updated, err
}
if fileErr := os.Remove(filePath); fileErr != nil && !errors.Is(fileErr, os.ErrNotExist) {
return updated, fmt.Errorf("failed to delete file: %w", fileErr)
}
// commit the transaction
if err = tx.Commit(); err != nil {
return updated, fmt.Errorf("failed to commit transaction: %w", err)
}
return updated, nil
}
func (f *Files) Delete(networkID *crypto.PublicKey, name string) error {
// look up network config
network, ok := f.cfg.NetworksByID[networkID.String()]
if !ok {
return fmt.Errorf("network %s is not configured", networkID)
}
// start a transaction
tx, err := f.signatures.BeginTx(true)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
// ensure it gets released when we're done
defer func() {
_ = tx.Rollback()
}()
// attempt to delete the signature
if err = f.signatures.Delete(tx, networkID, name); err != nil {
return fmt.Errorf("failed to delete signature: %w", err)
}
// remove the file, ignoring if it doesn't exist
delPath, err := f.FilePath(network, name)
if err != nil {
return err
}
if err = os.Remove(delPath); !os.IsNotExist(err) && err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
// commit the transaction
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func (f *Files) Close() error {
return f.signatures.Close()
}
// StateDir returns the state directory path for testing purposes.
func (f *Files) StateDir() string {
return f.cfg.StateDirectory
}
func recreateTempDir(path string) error {
// remove the tmp directory, it will be recreated on startup
err := os.RemoveAll(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove temp directory: %w", err)
}
// recreate the temp directory
if err := os.MkdirAll(path, 0o700); err != nil {
return fmt.Errorf("failed to create the temp directory: %w", err)
}
return nil
}
func checkIntegrity(f *Files) error {
slog.Info("checking file integrity")
// start a transaction
tx, err := f.signatures.BeginTx(true)
if err != nil {
return fmt.Errorf("failed to begin read transaction: %w", err)
}
// ensure it gets released when we're done
defer func() {
_ = tx.Rollback()
}()
// create a signatures reader
r, err := f.ListSignatures(tx)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("failed to list signatures: %w", err)
}
buf := make([]model.Signature, 16)
for {
n, readErr := r.Read(buf)
for _, sig := range buf[:n] {
if repairErr := f.repairSignature(tx, &sig); repairErr != nil {
return repairErr
}
}
if errors.Is(readErr, io.EOF) {
break
} else if readErr != nil {
return fmt.Errorf("failed to read signatures: %w", readErr)
}
}
// commit the transaction, as we want to complete the integrity check even if some signatures failed to verify }
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
slog.Info("file integrity check complete")
return nil
}
// repairSignature verifies a single signature and takes remedial action if it is invalid.
func (f *Files) repairSignature(tx *bolt.Tx, sig *model.Signature) error {
err := f.verifySignature(sig)
if err == nil {
return nil
}
switch {
case errors.Is(err, ErrFileNotFound):
slog.Warn("file no longer exists, removing", slog.String("file", sig.Name))
return f.removeSignature(tx, sig.Name)
case errors.Is(err, model.ErrSignatureNotValid):
slog.Warn("file is no longer valid, removing", slog.String("file", sig.Name))
return f.removeSignature(tx, sig.Name)
case errors.Is(err, ErrTombstoneFileExists):
slog.Warn("tombstoned file exists, removing", slog.String("file", sig.Name))
return f.removeFile(sig)
default:
return fmt.Errorf("failed to verify signature: %w", err)
}
}
func (f *Files) verifySignature(sig *model.Signature) error {
switch sig.Type {
case model.SignatureTypeTombstone:
return f.verifyTombstone(sig)
case model.SignatureTypeFile:
return f.verifyFile(sig)
default:
return fmt.Errorf("unknown signature type: %d", sig.Type)
}
}
func (f *Files) verifyTombstone(sig *model.Signature) error {
// look up network config
network, ok := f.cfg.NetworksByID[sig.NetworkID.String()]
if !ok {
return fmt.Errorf("network %s is not configured", sig.NetworkID)
}
if sig.Type != model.SignatureTypeTombstone {
return errors.New("signature is not a tombstone")
}
// verify there is no file on disk
tsPath, err := f.FilePath(network, sig.Name)
if err != nil {
return err
}
_, err = os.Stat(tsPath)
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: %s", ErrTombstoneFileExists, sig.Name)
}
return nil
}
func (f *Files) verifyFile(sig *model.Signature) error {
// look up network config
network, ok := f.cfg.NetworksByID[sig.NetworkID.String()]
if !ok {
return fmt.Errorf("network %s is not configured", sig.NetworkID)
}
// try to open a reader for the file
vfPath, err := f.FilePath(network, sig.Name)
if err != nil {
return err
}
r, err := os.Open(vfPath) //nolint:gosec // vfPath is constructed from validated network dir and name
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: %s", ErrFileNotFound, sig.Name)
} else if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
// verify it matches the signature
if err = sig.Verify(r); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
return nil
}
func (f *Files) removeFile(sig *model.Signature) error {
// look up network config
network, ok := f.cfg.NetworksByID[sig.NetworkID.String()]
if !ok {
return fmt.Errorf("network %s is not configured", sig.NetworkID)
}
rmPath, err := f.FilePath(network, sig.Name)
if err != nil {
return err
}
err = os.Remove(rmPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove tombstoned file %s: %w", sig.Name, err)
}
return nil
}
func (f *Files) removeSignature(tx *bolt.Tx, name string) error {
if err := f.signatures.Delete(tx, f.cfg.Network.ID, name); err != nil {
return fmt.Errorf(
"failed to remove signature for network %s, name %s: %w",
f.cfg.Network.ID, name, err,
)
}
return nil
}