Files
brianmcgee 27ea5601c5 {cmd, pkg}: remove direct calls to model.DefaultClock
Introduces a `config.Now` field which all components should defer to when getting the current time instead of hardcoded calls to `model.DefaultClock`.
2026-04-10 15:30:45 +01:00

1456 lines
39 KiB
Go

package state_test
import (
"errors"
"fmt"
"io"
"os"
"path"
"strings"
"testing"
"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"
"git.clan.lol/clan/data-mesher/pkg/state"
"git.clan.lol/clan/data-mesher/test"
"github.com/stretchr/testify/require"
)
const testFileName = "test_file"
func generateNetworkID(t *testing.T) *crypto.PublicKey {
t.Helper()
keys := test.GenerateKeys(t, 1)
return keys[0].Public
}
func newTestFiles(t *testing.T) (*config.Config, *state.Files) {
t.Helper()
tmpDir := t.TempDir()
networkKeys := test.GenerateKeys(t, 1)
networkID := networkKeys[0].Public
cfg := &config.Config{
Now: model.Now,
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly without full validation
// (validation requires HTTP config which we don't need for these tests)
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
files, err := state.NewFiles(cfg)
require.NoError(t, err, "failed to create Files")
t.Cleanup(func() {
_ = files.Close()
})
return cfg, files
}
func TestNewFiles(t *testing.T) {
t.Parallel()
t.Run("create files directory and signature store", func(t *testing.T) {
t.Parallel()
cfg, files := newTestFiles(t)
require.NotNil(t, cfg, "expected cfg to be non-nil")
require.NotNil(t, files, "expected files to be non-nil")
})
t.Run("create plugin directories under each network", func(t *testing.T) {
t.Parallel()
as := require.New(t)
tmpDir := t.TempDir()
networkKeys := test.GenerateKeys(t, 2)
homeID := networkKeys[0].Public
extraID := networkKeys[1].Public
cfg := &config.Config{
StateDirectory: tmpDir,
PluginDirectories: []string{"dns", "nss"},
Network: config.Network{
ID: homeID,
Files: config.Files{"test": nil},
},
}
cfg.Network.Name = config.DefaultNetworkName
extraNetwork := config.Network{
ID: extraID,
Name: "extra",
Files: config.Files{"test": nil},
}
cfg.NetworksByID = map[string]*config.Network{
homeID.String(): &cfg.Network,
extraID.String(): &extraNetwork,
}
files, err := state.NewFiles(cfg)
as.NoError(err)
defer func() { _ = files.Close() }()
// verify plugin dirs exist under both networks
for _, network := range []*config.Network{&cfg.Network, &extraNetwork} {
networkPath := path.Join(tmpDir, "files", network.ID.URLEncoded())
var info os.FileInfo
for _, dir := range cfg.PluginDirectories {
info, err = os.Stat(path.Join(networkPath, dir))
as.NoError(err, "plugin directory %s should exist under network %s", dir, network.Name)
as.True(info.IsDir(), "plugin path %s should be a directory", dir)
}
}
})
t.Run("fail with invalid path", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/foo/bar/baz/nonexistent",
}
_, err := state.NewFiles(cfg)
require.Error(t, err, "expected error for invalid path")
})
}
func TestFiles_WriteAndGet(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
content := "foo"
fileName := testFileName
// Sign the content
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
as.NoError(err, "failed to sign content")
// Write the file
written, err := files.Put(sig, strings.NewReader(content))
as.NoError(err, "failed to write file")
as.True(written, "expected file to be written")
// Get the file and verify content
reader, gotSig, err := files.Get(cfg.Network.ID, fileName)
as.NoError(err)
gotContent, err := io.ReadAll(reader)
as.NoError(err, "failed to read file content")
as.Equal(content, string(gotContent), "content mismatch")
// Verify signature fields match
as.Equal(sig.Type, gotSig.Type, "signature type mismatch")
as.Equal(sig.Name, gotSig.Name, "signature name mismatch")
as.True(sig.NetworkID.Equal(gotSig.NetworkID), "network ID mismatch")
as.Equal(sig.FileSize, gotSig.FileSize, "file size mismatch")
as.Equal(sig.SignedAt, gotSig.SignedAt, "signed at mismatch")
as.True(sig.SignedBy.Equal(gotSig.SignedBy), "signed by mismatch")
as.Equal(sig.Signature, gotSig.Signature, "signature bytes mismatch")
}
func TestFiles_Write_InvalidSignature(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
fileName := testFileName
content := t.Name()
otherContent := "different content"
// Sign different content than what we'll write
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(otherContent))
as.NoError(err, "failed to sign content")
// Attempt to write with mismatched signature
_, err = files.Put(sig, strings.NewReader(content))
as.ErrorIs(err, model.ErrSignatureNotValid, "expected ErrSignatureNotValid")
// Verify the file was not written
_, _, err = files.Get(cfg.Network.ID, fileName)
as.ErrorIs(err, state.ErrFileNotFound)
}
func TestFiles_Write_SkipsOlderSignature(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
fileName := testFileName
baseTime := time.Now().UTC()
originalContent := "original content"
olderContent := "older content"
// Create clock functions for different times
newerClock := func() time.Time { return baseTime }
olderClock := func() time.Time { return baseTime.Add(-time.Hour) }
// Sign and write the original content with newer timestamp
originalSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], newerClock, strings.NewReader(originalContent))
as.NoError(err)
_, err = files.Put(originalSig, strings.NewReader(originalContent))
as.NoError(err)
// Sign older content with older timestamp
olderSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], olderClock, strings.NewReader(olderContent))
as.NoError(err)
// Attempt to write with older content and signature
written, err := files.Put(olderSig, strings.NewReader(olderContent))
as.NoError(err)
as.False(written, "expected write to be skipped for older signature")
// Verify original content is still there
reader, _, err := files.Get(cfg.Network.ID, fileName)
as.NoError(err)
gotContent, err := io.ReadAll(reader)
as.NoError(err)
as.Equal(originalContent, string(gotContent), "original content should be preserved")
}
func TestFiles_Write_UpdatesNewerSignature(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
fileName := testFileName
baseTime := time.Now().UTC()
originalContent := "original content"
newerContent := "newer content"
// Create clock functions for different times (both in the past to avoid future-timestamp rejection)
olderClock := func() time.Time { return baseTime.Add(-time.Hour) }
newerClock := func() time.Time { return baseTime }
// Sign and write the original content with older timestamp
originalSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], olderClock, strings.NewReader(originalContent))
as.NoError(err)
_, err = files.Put(originalSig, strings.NewReader(originalContent))
as.NoError(err)
// Sign newer content with newer timestamp
newerSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], newerClock, strings.NewReader(newerContent))
as.NoError(err)
// Write with newer signature
written, err := files.Put(newerSig, strings.NewReader(newerContent))
as.NoError(err)
as.True(written, "expected write to succeed for newer signature")
// Verify newer content is now stored
reader, _, err := files.Get(cfg.Network.ID, fileName)
as.NoError(err)
gotContent, err := io.ReadAll(reader)
as.NoError(err)
as.Equal(newerContent, string(gotContent), "newer content should be stored")
}
func TestFiles_Get_NotFound(t *testing.T) {
t.Parallel()
cfg, files := newTestFiles(t)
_, _, err := files.Get(cfg.Network.ID, "nonexistent_file")
require.ErrorIs(t, err, state.ErrFileNotFound)
}
func TestFiles_Delete(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
content := "content to delete"
fileName := "to_delete"
// Sign and write the file
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
as.NoError(err)
_, err = files.Put(sig, strings.NewReader(content))
as.NoError(err)
// Delete the file
err = files.Delete(cfg.Network.ID, fileName)
as.NoError(err)
// Verify file is gone
_, _, err = files.Get(cfg.Network.ID, fileName)
as.ErrorIs(err, state.ErrFileNotFound)
}
func TestFiles_List_Empty(t *testing.T) {
t.Parallel()
as := require.New(t)
_, files := newTestFiles(t)
tx, err := files.BeginTx(false)
as.NoError(err)
defer tx.Rollback() //nolint:errcheck
reader, err := files.ListSignatures(tx)
as.NoError(err)
buf := make([]model.Signature, 16)
result := make([]model.Signature, 0)
for {
n, readErr := reader.Read(buf)
for i := range n {
result = append(result, buf[i])
}
if errors.Is(readErr, io.EOF) {
break
}
as.NoError(readErr)
}
as.Empty(result)
}
func TestFiles_List_WithFiles(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
// Write some files (including path-style names with subdirectories)
testFiles := []struct {
name string
content string
}{
{"file1", "content 1"},
{"subdir/file2", "content 2"},
{"nested/path/file3", "content 3"},
}
for _, tf := range testFiles {
sig, err := model.SignFile(tf.name, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(tf.content))
as.NoError(err)
_, err = files.Put(sig, strings.NewReader(tf.content))
as.NoError(err)
}
// List files
tx, err := files.BeginTx(false)
as.NoError(err)
defer tx.Rollback() //nolint:errcheck
reader, err := files.ListSignatures(tx)
as.NoError(err)
buf := make([]model.Signature, 16)
result := make([]model.Signature, 0)
for {
n, readErr := reader.Read(buf)
for i := range n {
result = append(result, buf[i])
}
if errors.Is(readErr, io.EOF) {
break
}
as.NoError(readErr)
}
as.Len(result, 3)
// Check file names and signatures are present
names := make(map[string]bool)
for _, sig := range result {
names[sig.Name] = true
as.NotNil(sig.SignedBy, "expected SignedBy to be set for %s", sig.Name)
as.NotEmpty(sig.SignedAt, "expected SignedAt to be set for %s", sig.Name)
as.NotEmpty(sig.Signature, "expected Signature to be set for %s", sig.Name)
}
as.True(names["file1"])
as.True(names["subdir/file2"])
as.True(names["nested/path/file3"])
}
func TestFiles_IntegrityCheck_RemoveMissingFiles(t *testing.T) {
t.Parallel()
as := require.New(t)
// Create a new temp state directory
tmpDir := t.TempDir()
networkID := generateNetworkID(t)
cfg := &config.Config{
Now: model.Now,
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
// Create an initial files store and add some files
files, err := state.NewFiles(cfg)
as.NoError(err)
keys := test.GenerateKeys(t, 1)
// Write two files
contentOne := "content for file 1"
contentTwo := "content for file 2"
sigOne, err := model.SignFile("file1", cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(contentOne))
as.NoError(err)
_, err = files.Put(sigOne, strings.NewReader(contentOne))
as.NoError(err)
sigTwo, err := model.SignFile("file2", cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(contentTwo))
as.NoError(err)
_, err = files.Put(sigTwo, strings.NewReader(contentTwo))
as.NoError(err)
// Close the store
as.NoError(files.Close())
// Delete file1.txt from disk (simulate a missing file)
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, "file1")
as.NoError(os.Remove(filePath))
// Re-open the store, which performs an integrity check
files, err = state.NewFiles(cfg)
as.NoError(err)
//nolint:errcheck
defer files.Close()
// file1.txt should be gone (signature removed due to missing file)
_, _, err = files.Get(cfg.Network.ID, "file1")
as.ErrorIs(err, state.ErrFileNotFound, "file1.txt signature should have been removed")
// file2.txt should still exist
reader, _, err := files.Get(cfg.Network.ID, "file2")
as.NoError(err, "file2.txt should still exist")
content, err := io.ReadAll(reader)
as.NoError(err)
as.Equal(contentTwo, string(content))
}
func TestFiles_IntegrityCheck_RemoveCorruptedFiles(t *testing.T) {
t.Parallel()
as := require.New(t)
// Create a new temp state directory
tmpDir := t.TempDir()
networkID := generateNetworkID(t)
cfg := &config.Config{
Now: model.Now,
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
// Create an initial files store and add some files
files, err := state.NewFiles(cfg)
as.NoError(err)
keys := test.GenerateKeys(t, 1)
// Write two files
contentOne := "content for file 1"
contentTwo := "content for file 2"
sigOne, err := model.SignFile("file1", cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(contentOne))
as.NoError(err)
_, err = files.Put(sigOne, strings.NewReader(contentOne))
as.NoError(err)
sigTwo, err := model.SignFile("file2", cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(contentTwo))
as.NoError(err)
_, err = files.Put(sigTwo, strings.NewReader(contentTwo))
as.NoError(err)
// Close the store
as.NoError(files.Close())
// Corrupt file1.txt by changing its content
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, "file1")
as.NoError(os.WriteFile(filePath, []byte("corrupted content!!!"), 0o600))
// Re-open the store, which performs an integrity check
files, err = state.NewFiles(cfg)
as.NoError(err)
//nolint:errcheck
defer files.Close()
// file1.txt should be gone (signature removed due to invalid content)
_, _, err = files.Get(cfg.Network.ID, "file1")
as.ErrorIs(err, state.ErrFileNotFound, "file1.txt signature should have been removed")
// file2.txt should still exist
reader, _, err := files.Get(cfg.Network.ID, "file2")
as.NoError(err, "file2.txt should still exist")
content, err := io.ReadAll(reader)
as.NoError(err)
as.Equal(contentTwo, string(content))
}
func TestFiles_IntegrityCheck_PreserveValidFiles(t *testing.T) {
t.Parallel()
as := require.New(t)
tmpDir := t.TempDir()
networkID := generateNetworkID(t)
cfg := &config.Config{
Now: model.Now,
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
// Create an initial files store and add some files
files, err := state.NewFiles(cfg)
as.NoError(err)
keys := test.GenerateKeys(t, 1)
// Write multiple files (including path-style names with subdirectories)
testFiles := []struct {
name string
content string
}{
{"file1", "content 1"},
{"dns/zone1", "content 2"},
{"dns/zones/zone2", "content 3"},
}
for _, tf := range testFiles {
sig, sigErr := model.SignFile(tf.name, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(tf.content))
as.NoError(sigErr)
_, putErr := files.Put(sig, strings.NewReader(tf.content))
as.NoError(putErr)
}
// Close the store
as.NoError(files.Close())
// Re-open the store, which performs an integrity check
files, err = state.NewFiles(cfg)
as.NoError(err)
//nolint:errcheck
defer files.Close()
// All files should still exist with the same content
for _, tf := range testFiles {
r, _, getErr := files.Get(cfg.Network.ID, tf.name)
as.NoError(getErr, "expected %s to exist", tf.name)
content, readErr := io.ReadAll(r)
as.NoError(readErr)
as.Equal(tf.content, string(content), "content mismatch for %s", tf.name)
}
}
func TestFiles_TempDir_RecreatedOnStartup(t *testing.T) {
t.Parallel()
as := require.New(t)
networkID := generateNetworkID(t)
cfg := &config.Config{
StateDirectory: t.TempDir(),
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
// Create an initial files store
files, err := state.NewFiles(cfg)
as.NoError(err)
as.NoError(files.Close())
// Simulate leftover partial downloads by creating files in the temp directory
tmpDir := path.Join(cfg.StateDirectory, state.DirTemp)
leftoverFile := tmpDir + "/partial-download.tmp"
as.NoError(os.WriteFile(leftoverFile, []byte("partial content"), 0o600))
// Verify the leftover file exists
_, err = os.Stat(leftoverFile)
as.NoError(err, "leftover file should exist before restart")
// Re-open the store (simulating a restart)
files, err = state.NewFiles(cfg)
as.NoError(err)
//nolint:errcheck
defer files.Close()
// The leftover file should have been removed
_, err = os.Stat(leftoverFile)
as.True(os.IsNotExist(err), "leftover file should have been removed on startup")
// The temp directory should still exist (recreated)
info, err := os.Stat(tmpDir)
as.NoError(err, "temp directory should exist")
as.True(info.IsDir(), "temp path should be a directory")
}
func TestFiles_TempDir_CreatedOnFirstStartup(t *testing.T) {
t.Parallel()
as := require.New(t)
networkID := generateNetworkID(t)
cfg := &config.Config{
StateDirectory: t.TempDir(),
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
// Verify temp directory doesn't exist yet
tmpDir := path.Join(cfg.StateDirectory, state.DirTemp)
_, err := os.Stat(tmpDir)
as.True(os.IsNotExist(err), "temp directory should not exist before NewFiles")
// Create a new files store
files, err := state.NewFiles(cfg)
as.NoError(err)
//nolint:errcheck
defer files.Close()
// The temp directory should now exist
info, err := os.Stat(tmpDir)
as.NoError(err, "temp directory should exist after NewFiles")
as.True(info.IsDir(), "temp path should be a directory")
}
func TestFiles_Tombstone_DeletesFile(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
content := "hello world"
// First, write a file
sig, err := model.SignFile(testFileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
as.NoError(err)
written, err := files.Put(sig, strings.NewReader(content))
as.NoError(err)
as.True(written)
// Verify file exists
reader, _, err := files.Get(cfg.Network.ID, testFileName)
as.NoError(err)
gotContent, err := io.ReadAll(reader)
as.NoError(err)
as.Equal(content, string(gotContent))
// Create a tombstone with a later timestamp
laterClock := func() time.Time { return sig.SignedAt.Add(time.Second) }
tombstone, err := model.SignTombstone(testFileName, cfg.Network.ID, keys[0], laterClock)
as.NoError(err)
// Put the tombstone
written, err = files.PutTombstone(tombstone)
as.NoError(err)
as.True(written, "expected tombstone to be written")
// File should no longer be accessible via Get
_, _, err = files.Get(cfg.Network.ID, testFileName)
as.ErrorIs(err, state.ErrFileNotFound, "expected file to be deleted")
}
func TestFiles_Tombstone_PreventsOlderFile(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
fileName := "test_file"
baseTime := time.Now().UTC()
// Create a tombstone first
tombstoneClock := func() time.Time { return baseTime }
tombstone, err := model.SignTombstone(fileName, cfg.Network.ID, keys[0], tombstoneClock)
as.NoError(err)
written, err := files.PutTombstone(tombstone)
as.NoError(err)
as.True(written)
// Try to write an older file (should be rejected)
olderClock := func() time.Time { return baseTime.Add(-time.Hour) }
content := "old content"
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], olderClock, strings.NewReader(content))
as.NoError(err)
written, err = files.Put(sig, strings.NewReader(content))
as.NoError(err)
as.False(written, "expected older file to be rejected")
// File should still not exist
_, _, err = files.Get(cfg.Network.ID, fileName)
as.ErrorIs(err, state.ErrFileNotFound)
}
func TestFiles_Tombstone_ResurrectionWithNewerFile(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
fileName := "test_file"
baseTime := time.Now().UTC()
// Create a tombstone first
tombstoneClock := func() time.Time { return baseTime.Add(-time.Second) }
tombstone, err := model.SignTombstone(fileName, cfg.Network.ID, keys[0], tombstoneClock)
as.NoError(err)
written, err := files.PutTombstone(tombstone)
as.NoError(err)
as.True(written)
// Write a newer file (resurrection)
newerClock := func() time.Time { return baseTime }
content := "resurrected content"
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], newerClock, strings.NewReader(content))
as.NoError(err)
written, err = files.Put(sig, strings.NewReader(content))
as.NoError(err)
as.True(written, "expected newer file to be written (resurrection)")
// File should now exist
reader, _, err := files.Get(cfg.Network.ID, fileName)
as.NoError(err)
gotContent, err := io.ReadAll(reader)
as.NoError(err)
as.Equal(content, string(gotContent))
}
func TestFiles_IntegrityCheck_SkipsTombstones(t *testing.T) {
t.Parallel()
as := require.New(t)
tmpDir := t.TempDir()
networkID := generateNetworkID(t)
cfg := &config.Config{
Now: model.Now,
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
// Create a files store and add a tombstone
files, err := state.NewFiles(cfg)
as.NoError(err)
keys := test.GenerateKeys(t, 1)
// Create a tombstone
tombstone, err := model.SignTombstone("deleted_file", cfg.Network.ID, keys[0], cfg.Now)
as.NoError(err)
written, err := files.PutTombstone(tombstone)
as.NoError(err)
as.True(written)
// Close and reopen (triggers integrity check)
as.NoError(files.Close())
// Reopen - integrity check should not remove the tombstone
files, err = state.NewFiles(cfg)
as.NoError(err)
//nolint:errcheck
defer files.Close()
// Tombstone should still be there (Get returns ErrUnauthorizedFile for tombstones)
_, _, err = files.Get(cfg.Network.ID, "deleted_file")
as.ErrorIs(err, state.ErrFileNotFound, "tombstone should still prevent file access")
}
func TestFiles_Put_SetsReadonlyPermissions(t *testing.T) {
t.Parallel()
as := require.New(t)
tmpDir := t.TempDir()
networkID := generateNetworkID(t)
cfg := &config.Config{
Now: model.Now,
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
files, err := state.NewFiles(cfg)
as.NoError(err)
defer func() {
_ = files.Close()
}()
keys := test.GenerateKeys(t, 1)
content := "test content"
fileName := "permissions_test"
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
as.NoError(err)
written, err := files.Put(sig, strings.NewReader(content))
as.NoError(err)
as.True(written)
// Stat the file and check its permissions
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, fileName)
info, err := os.Stat(filePath)
as.NoError(err)
// The file should be readonly (0o644: owner rw, group/others read-only)
as.Equal(os.FileMode(0o644), info.Mode().Perm(), "file should have readonly permissions (0644)")
}
func TestFiles_Put_SetsMtimeToSignedAt(t *testing.T) {
t.Parallel()
as := require.New(t)
tmpDir := t.TempDir()
networkID := generateNetworkID(t)
cfg := &config.Config{
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
files, err := state.NewFiles(cfg)
as.NoError(err)
defer files.Close() //nolint:errcheck
keys := test.GenerateKeys(t, 1)
content := "test content"
fileName := "mtime_test"
// Use a specific timestamp in the past
signedAt := time.Date(2020, 6, 15, 12, 30, 0, 0, time.UTC)
clock := func() time.Time { return signedAt }
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], clock, strings.NewReader(content))
as.NoError(err)
written, err := files.Put(sig, strings.NewReader(content))
as.NoError(err)
as.True(written)
// Stat the file and check its mtime
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, fileName)
info, err := os.Stat(filePath)
as.NoError(err)
// The mtime should match the signature's SignedAt
as.Equal(signedAt, info.ModTime().UTC(), "file mtime should match signature SignedAt")
}
func TestFiles_Put_UpdatesMtimeOnNewerSignature(t *testing.T) {
t.Parallel()
as := require.New(t)
tmpDir := t.TempDir()
networkID := generateNetworkID(t)
cfg := &config.Config{
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
files, err := state.NewFiles(cfg)
as.NoError(err)
defer files.Close() //nolint:errcheck
keys := test.GenerateKeys(t, 1)
fileName := "mtime_update_test"
// First file with older timestamp
olderTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
olderClock := func() time.Time { return olderTime }
olderContent := "older content"
oldSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], olderClock, strings.NewReader(olderContent))
as.NoError(err)
written, err := files.Put(oldSig, strings.NewReader(olderContent))
as.NoError(err)
as.True(written)
// Verify initial mtime
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, fileName)
info, err := os.Stat(filePath)
as.NoError(err)
as.Equal(olderTime, info.ModTime().UTC(), "initial mtime should match older SignedAt")
// Update with newer timestamp
newerTime := time.Date(2021, 6, 15, 12, 0, 0, 0, time.UTC)
newerClock := func() time.Time { return newerTime }
newerContent := "newer content"
newerSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], newerClock, strings.NewReader(newerContent))
as.NoError(err)
written, err = files.Put(newerSig, strings.NewReader(newerContent))
as.NoError(err)
as.True(written)
// Verify updated mtime
info, err = os.Stat(filePath)
as.NoError(err)
as.Equal(newerTime, info.ModTime().UTC(), "updated mtime should match newer SignedAt")
}
func TestFiles_Put_RejectsSizeMismatch(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
fileName := "size_mismatch"
// Sign content to get a valid signature
originalContent := "original content for size test"
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(originalContent))
as.NoError(err)
as.Equal(int64(len(originalContent)), sig.FileSize)
// Try to put with different content length than declared file size
shorterContent := "hello"
_, err = files.Put(sig, strings.NewReader(shorterContent))
as.ErrorIs(err, model.ErrSignatureNotValid)
as.ErrorContains(err, "file size mismatch")
// File should not have been written
_, _, err = files.Get(cfg.Network.ID, fileName)
as.ErrorIs(err, state.ErrFileNotFound)
}
func TestFiles_IntegrityCheck_RemovesTombstonedFiles(t *testing.T) {
t.Parallel()
as := require.New(t)
tmpDir := t.TempDir()
networkID := generateNetworkID(t)
cfg := &config.Config{
Now: model.Now,
StateDirectory: tmpDir,
Network: config.Network{
ID: networkID,
Files: config.Files{"test": nil},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
cfg.NetworksByID = map[string]*config.Network{
networkID.String(): &cfg.Network,
}
// Create a files store
files, err := state.NewFiles(cfg)
as.NoError(err)
keys := test.GenerateKeys(t, 1)
fileName := "orphaned_file"
content := "this file should be removed"
// First, write a file
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
as.NoError(err)
written, err := files.Put(sig, strings.NewReader(content))
as.NoError(err)
as.True(written)
// Create a tombstone (this deletes the file from disk)
laterClock := func() time.Time { return sig.SignedAt.Add(time.Second) }
tombstone, err := model.SignTombstone(fileName, cfg.Network.ID, keys[0], laterClock)
as.NoError(err)
written, err = files.PutTombstone(tombstone)
as.NoError(err)
as.True(written)
// Close the store
as.NoError(files.Close())
// Manually recreate the file on disk (simulating an orphaned file)
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, fileName)
as.NoError(os.MkdirAll(path.Dir(filePath), 0o750))
as.NoError(os.WriteFile(filePath, []byte(content), 0o600))
// Verify the orphaned file exists
_, err = os.Stat(filePath)
as.NoError(err, "orphaned file should exist before integrity check")
// Reopen the store (triggers integrity check)
files, err = state.NewFiles(cfg)
as.NoError(err)
//nolint:errcheck
defer files.Close()
// The orphaned file should have been removed by the integrity check
_, err = os.Stat(filePath)
as.True(os.IsNotExist(err), "orphaned file should have been removed by integrity check")
// Get should still return ErrUnauthorizedFile (tombstone is still in place)
_, _, err = files.Get(cfg.Network.ID, fileName)
as.ErrorIs(err, state.ErrFileNotFound, "tombstone should still be in effect")
}
func TestFiles_IntegrityCheck_MultipleNetworks(t *testing.T) {
t.Parallel()
as := require.New(t)
tmpDir := t.TempDir()
// Create 3 network IDs; network 0 is the configured network
networkKeys := test.GenerateKeys(t, 3)
networkIDs := make([]*crypto.PublicKey, 3)
for i := range networkIDs {
networkIDs[i] = networkKeys[i].Public
}
cfg := &config.Config{
Now: model.Now,
StateDirectory: tmpDir,
Network: config.Network{
ID: networkIDs[0],
Files: config.Files{"test": nil},
},
ExtraNetworks: config.ExtraNetworks{
"net1": config.Network{
ID: networkIDs[1],
Files: config.Files{"test": nil},
Name: "net1",
},
"net2": config.Network{
ID: networkIDs[2],
Files: config.Files{"test": nil},
Name: "net2",
},
},
}
// Populate NetworksByID map directly
cfg.Network.Name = config.DefaultNetworkName
net1 := cfg.ExtraNetworks["net1"]
net2 := cfg.ExtraNetworks["net2"]
cfg.NetworksByID = map[string]*config.Network{
networkIDs[0].String(): &cfg.Network,
networkIDs[1].String(): &net1,
networkIDs[2].String(): &net2,
}
// Create the files store
files, err := state.NewFiles(cfg)
as.NoError(err)
signerKeys := test.GenerateKeys(t, 1)
signer := signerKeys[0]
const filesPerNetwork = 10
// Track file names per network for later verification
type fileEntry struct {
name string
content string
}
allFiles := make([][]fileEntry, 3)
for i := range 3 {
allFiles[i] = make([]fileEntry, filesPerNetwork)
for j := range filesPerNetwork {
name := fmt.Sprintf("net%d/file_%d", i, j)
content := fmt.Sprintf("content-net%d-file%d", i, j)
clock := test.Clock(time.Now().UTC().Add(time.Duration(i*filesPerNetwork+j) * time.Second))
sig, signErr := model.SignFile(name, networkIDs[i], signer, clock, strings.NewReader(content))
as.NoError(signErr, "failed to sign %s", name)
_, putErr := files.Put(sig, strings.NewReader(content))
as.NoError(putErr, "failed to put %s", name)
allFiles[i][j] = fileEntry{name: name, content: content}
}
}
// Close the store before corrupting files
as.NoError(files.Close())
// Extract per-network locals for readability.
net0ID := networkIDs[0]
net0Files := allFiles[0]
net1ID := networkIDs[1]
net1Files := allFiles[1]
net2ID := networkIDs[2]
net2Files := allFiles[2]
// --- Introduce corruption ---
// Network 0 (configured network): delete 2 files, corrupt 2 files, leave 6 intact
// Delete files 0 and 1
for _, idx := range []int{0, 1} {
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, net0Files[idx].name)
as.NoError(os.Remove(filePath), "failed to delete %s", filePath)
}
// Corrupt files 2 and 3
for _, idx := range []int{2, 3} {
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, net0Files[idx].name)
as.NoError(os.WriteFile(filePath, []byte("garbage-data"), 0o600), "failed to corrupt %s", filePath)
}
// Network 1: delete 1 file (file 0)
{
filePath := path.Join(tmpDir, state.DirFiles, "net1", net1Files[0].name)
as.NoError(os.Remove(filePath), "failed to delete %s", filePath)
}
// Network 2: all intact (no changes)
// --- Reopen the store (triggers checkIntegrity) ---
files, err = state.NewFiles(cfg)
as.NoError(err)
defer files.Close() //nolint:errcheck
// --- Verify network 0 ---
// Files 0-3 should be gone (2 deleted + 2 corrupted), files 4-9 should be intact
for idx := range 4 {
_, _, getErr := files.Get(net0ID, net0Files[idx].name)
as.ErrorIs(getErr, state.ErrFileNotFound,
"network 0 file %d (%s) should be gone", idx, net0Files[idx].name)
}
for idx := 4; idx < filesPerNetwork; idx++ {
reader, _, getErr := files.Get(net0ID, net0Files[idx].name)
as.NoError(getErr,
"network 0 file %d (%s) should be accessible", idx, net0Files[idx].name)
content, readErr := io.ReadAll(reader)
as.NoError(readErr)
as.Equal(net0Files[idx].content, string(content))
}
// --- Verify network 1 ---
// File 0 was deleted from disk, so integrity check should have removed its signature
_, _, getErr := files.Get(net1ID, net1Files[0].name)
as.ErrorIs(getErr, state.ErrFileNotFound,
"network 1 file 0 should have been removed by integrity check")
// The remaining 9 intact files from network 1 should be accessible
for idx := 1; idx < filesPerNetwork; idx++ {
reader, _, getErr := files.Get(net1ID, net1Files[idx].name)
as.NoError(getErr,
"network 1 file %d (%s) should be accessible", idx, net1Files[idx].name)
content, readErr := io.ReadAll(reader)
as.NoError(readErr)
as.Equal(net1Files[idx].content, string(content))
}
// --- Verify network 2 ---
// All 10 files should be intact
for idx := range filesPerNetwork {
reader, _, getErr := files.Get(net2ID, net2Files[idx].name)
as.NoError(getErr,
"network 2 file %d (%s) should be accessible", idx, net2Files[idx].name)
content, readErr := io.ReadAll(reader)
as.NoError(readErr)
as.Equal(net2Files[idx].content, string(content))
}
}
// writeWithTTL is a small test helper that signs and writes a file with the
// given clock and validFor, mirroring how a CLI client would publish.
func writeWithTTL(
t *testing.T,
files *state.Files,
networkID *crypto.PublicKey,
signer *crypto.PrivateKey,
clock model.Clock,
validFor time.Duration,
name, content string,
) {
t.Helper()
sig, err := model.SignFileWithTTL(name, networkID, signer, clock, validFor, strings.NewReader(content))
require.NoError(t, err)
written, err := files.Put(sig, strings.NewReader(content))
require.NoError(t, err)
require.True(t, written)
}
func TestFiles_Get_ExpiredReturnsNotFound(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
// Sign with SignedAt 1 hour in the past, ValidFor 1 minute → already
// expired by the time Get runs against the real clock.
pastClock := func() time.Time { return time.Now().Add(-1 * time.Hour) }
writeWithTTL(t, files, cfg.Network.ID, keys[0], pastClock, time.Minute, "expired", "stale content")
_, _, err := files.Get(cfg.Network.ID, "expired")
as.ErrorIs(err, state.ErrFileNotFound, "expected expired file to be hidden from Get")
}
func TestFiles_Get_FutureExpiryReturnsFile(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
// Sign now with a generous ValidFor — should be readable.
writeWithTTL(t, files, cfg.Network.ID, keys[0], cfg.Now, time.Hour, "fresh", "live content")
reader, sig, err := files.Get(cfg.Network.ID, "fresh")
as.NoError(err)
as.Equal(time.Hour, sig.ValidFor)
got, err := io.ReadAll(reader)
as.NoError(err)
as.Equal("live content", string(got))
}
func TestFiles_Get_ZeroValidForReturnsFile(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
// ValidFor=0 means "no expiry" — Get must return the file regardless of
// how old SignedAt is.
pastClock := func() time.Time { return time.Now().Add(-100 * 365 * 24 * time.Hour) }
writeWithTTL(t, files, cfg.Network.ID, keys[0], pastClock, 0, "ancient", "still good")
reader, _, err := files.Get(cfg.Network.ID, "ancient")
as.NoError(err)
got, err := io.ReadAll(reader)
as.NoError(err)
as.Equal("still good", string(got))
}
func TestFiles_SweepExpired(t *testing.T) {
t.Parallel()
as := require.New(t)
cfg, files := newTestFiles(t)
keys := test.GenerateKeys(t, 1)
freshClock := cfg.Now
pastClock := func() time.Time { return cfg.Now().Add(-1 * time.Hour) }
// One expired (SignedAt past, short TTL), one not expired (long TTL),
// one no-expiry (TTL=0).
writeWithTTL(t, files, cfg.Network.ID, keys[0], pastClock, time.Minute, "expired", "stale")
writeWithTTL(t, files, cfg.Network.ID, keys[0], freshClock, time.Hour, "fresh", "live")
writeWithTTL(t, files, cfg.Network.ID, keys[0], pastClock, 0, "forever", "no expiry")
// Sweep at "now": only "expired" should be removed.
removed, err := files.SweepExpired(freshClock())
as.NoError(err)
as.Equal(1, removed, "exactly one expired entry should have been swept")
// Confirm the expired sig + file are gone.
_, _, err = files.Get(cfg.Network.ID, "expired")
as.ErrorIs(err, state.ErrFileNotFound)
// Confirm the fresh sig is still readable.
reader, _, err := files.Get(cfg.Network.ID, "fresh")
as.NoError(err)
got, err := io.ReadAll(reader)
as.NoError(err)
as.Equal("live", string(got))
// Confirm the no-expiry sig is still readable.
reader, _, err = files.Get(cfg.Network.ID, "forever")
as.NoError(err)
got, err = io.ReadAll(reader)
as.NoError(err)
as.Equal("no expiry", string(got))
// Idempotency: a second sweep should be a no-op.
removed, err = files.SweepExpired(time.Now())
as.NoError(err)
as.Equal(0, removed, "second sweep should remove nothing")
}