fs storage
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/model/client"
|
||||
"galaxy/model/order"
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
const testTimeout = time.Second
|
||||
|
||||
type callbackResult[T any] struct {
|
||||
value T
|
||||
err error
|
||||
}
|
||||
|
||||
func TestStateRoundTripAsync(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
want := sampleState()
|
||||
|
||||
saveDone := make(chan error, 1)
|
||||
s.SaveState(want, func(err error) {
|
||||
saveDone <- err
|
||||
})
|
||||
if err := waitError(t, saveDone); err != nil {
|
||||
t.Fatalf("save state: %v", err)
|
||||
}
|
||||
|
||||
existsDone := make(chan callbackResult[bool], 1)
|
||||
s.StateExists(func(ok bool, err error) {
|
||||
existsDone <- callbackResult[bool]{value: ok, err: err}
|
||||
})
|
||||
exists := waitResult(t, existsDone)
|
||||
if exists.err != nil {
|
||||
t.Fatalf("state exists: %v", exists.err)
|
||||
}
|
||||
if !exists.value {
|
||||
t.Fatal("state file should exist after save")
|
||||
}
|
||||
|
||||
loadDone := make(chan callbackResult[client.State], 1)
|
||||
s.LoadState(func(state client.State, err error) {
|
||||
loadDone <- callbackResult[client.State]{value: state, err: err}
|
||||
})
|
||||
got := waitResult(t, loadDone)
|
||||
if got.err != nil {
|
||||
t.Fatalf("load state: %v", got.err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.value, want) {
|
||||
t.Fatalf("loaded state mismatch\nwant: %#v\ngot: %#v", want, got.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportAndOrderRoundTripAsync(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
id := client.GameID("game-1")
|
||||
turn := uint(7)
|
||||
initialReport := sampleReport(turn, "Terran")
|
||||
updatedReport := sampleReport(turn, "Zenith")
|
||||
wantOrder := sampleOrder()
|
||||
|
||||
saveReportDone := make(chan error, 1)
|
||||
s.SaveReport(id, turn, initialReport, func(err error) {
|
||||
saveReportDone <- err
|
||||
})
|
||||
if err := waitError(t, saveReportDone); err != nil {
|
||||
t.Fatalf("save report: %v", err)
|
||||
}
|
||||
|
||||
saveOrderDone := make(chan error, 1)
|
||||
s.SaveOrder(id, turn, wantOrder, func(err error) {
|
||||
saveOrderDone <- err
|
||||
})
|
||||
if err := waitError(t, saveOrderDone); err != nil {
|
||||
t.Fatalf("save order: %v", err)
|
||||
}
|
||||
|
||||
saveUpdatedReportDone := make(chan error, 1)
|
||||
s.SaveReport(id, turn, updatedReport, func(err error) {
|
||||
saveUpdatedReportDone <- err
|
||||
})
|
||||
if err := waitError(t, saveUpdatedReportDone); err != nil {
|
||||
t.Fatalf("save updated report: %v", err)
|
||||
}
|
||||
|
||||
loadReportDone := make(chan callbackResult[report.Report], 1)
|
||||
s.LoadReport(id, turn, func(rep report.Report, err error) {
|
||||
loadReportDone <- callbackResult[report.Report]{value: rep, err: err}
|
||||
})
|
||||
gotReport := waitResult(t, loadReportDone)
|
||||
if gotReport.err != nil {
|
||||
t.Fatalf("load report: %v", gotReport.err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotReport.value, updatedReport) {
|
||||
t.Fatalf("loaded report mismatch\nwant: %#v\ngot: %#v", updatedReport, gotReport.value)
|
||||
}
|
||||
|
||||
loadOrderDone := make(chan callbackResult[order.Order], 1)
|
||||
s.LoadOrder(id, turn, func(got order.Order, err error) {
|
||||
loadOrderDone <- callbackResult[order.Order]{value: got, err: err}
|
||||
})
|
||||
gotOrder := waitResult(t, loadOrderDone)
|
||||
if gotOrder.err != nil {
|
||||
t.Fatalf("load order: %v", gotOrder.err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotOrder.value, wantOrder) {
|
||||
t.Fatalf("loaded order mismatch\nwant: %#v\ngot: %#v", wantOrder, gotOrder.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOrderBeforeReportReturnsNotExist(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
|
||||
done := make(chan error, 1)
|
||||
s.SaveOrder("game-2", 3, sampleOrder(), func(err error) {
|
||||
done <- err
|
||||
})
|
||||
err := waitError(t, done)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("save order error = %v, want os.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawFileCRUDAndList(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
|
||||
if err := s.WriteFile("/nested/alpha.txt", []byte("alpha")); err != nil {
|
||||
t.Fatalf("write alpha: %v", err)
|
||||
}
|
||||
if err := s.WriteFile("beta.txt", []byte("beta")); err != nil {
|
||||
t.Fatalf("write beta: %v", err)
|
||||
}
|
||||
|
||||
alphaExists, err := s.FileExists("nested/alpha.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("file exists: %v", err)
|
||||
}
|
||||
if !alphaExists {
|
||||
t.Fatal("nested/alpha.txt should exist")
|
||||
}
|
||||
|
||||
alphaData, err := s.ReadFile("nested/alpha.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("read alpha: %v", err)
|
||||
}
|
||||
if string(alphaData) != "alpha" {
|
||||
t.Fatalf("read alpha = %q, want %q", alphaData, "alpha")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+newFileSuffix), []byte("tmp"), 0o644); err != nil {
|
||||
t.Fatalf("create stale .new file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+oldFileSuffix), []byte("tmp"), 0o644); err != nil {
|
||||
t.Fatalf("create stale .old file: %v", err)
|
||||
}
|
||||
|
||||
files, err := s.ListFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("list files: %v", err)
|
||||
}
|
||||
wantFiles := []string{
|
||||
"beta.txt",
|
||||
filepath.Join("nested", "alpha.txt"),
|
||||
}
|
||||
if !reflect.DeepEqual(files, wantFiles) {
|
||||
t.Fatalf("listed files mismatch\nwant: %#v\ngot: %#v", wantFiles, files)
|
||||
}
|
||||
|
||||
if err := s.DeleteFile("beta.txt"); err != nil {
|
||||
t.Fatalf("delete beta: %v", err)
|
||||
}
|
||||
if err := s.DeleteFile("beta.txt"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("delete missing beta error = %v, want os.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
|
||||
for _, path := range []string{"../escape.txt", "..\\escape.txt", ""} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
err := s.WriteFile(path, []byte("blocked"))
|
||||
if err == nil {
|
||||
t.Fatalf("write %q unexpectedly succeeded", path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWriteFirstAndOverwrite(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := filepath.Join("turns", "12.bin")
|
||||
|
||||
if err := s.WriteFile(target, []byte("first")); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
assertFileContent(t, s, target, "first")
|
||||
assertNoTempArtifacts(t, s, target)
|
||||
|
||||
if err := s.WriteFile(target, []byte("second")); err != nil {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
assertFileContent(t, s, target, "second")
|
||||
assertNoTempArtifacts(t, s, target)
|
||||
}
|
||||
|
||||
func TestAtomicWriteStaleTempCollision(t *testing.T) {
|
||||
t.Run("stale new file", func(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := "collision-new.txt"
|
||||
absTarget, err := s.resolvePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absTarget), os.ModePerm); err != nil {
|
||||
t.Fatalf("create parent dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(absTarget+newFileSuffix, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatalf("write stale new file: %v", err)
|
||||
}
|
||||
|
||||
err = s.WriteFile(target, []byte("payload"))
|
||||
if err == nil || !strings.Contains(err.Error(), "new file already exists") {
|
||||
t.Fatalf("write error = %v, want stale new file error", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale old file", func(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := "collision-old.txt"
|
||||
absTarget, err := s.resolvePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(absTarget, []byte("current"), 0o644); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(absTarget+oldFileSuffix, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatalf("write stale old file: %v", err)
|
||||
}
|
||||
|
||||
err = s.WriteFile(target, []byte("payload"))
|
||||
if err == nil || !strings.Contains(err.Error(), "old file already exists") {
|
||||
t.Fatalf("write error = %v, want stale old file error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAtomicWriteRollbackOnRenameFailure(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := filepath.Join("rollback", "state.txt")
|
||||
absTarget, err := s.resolvePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
|
||||
if err := s.WriteFile(target, []byte("original")); err != nil {
|
||||
t.Fatalf("seed target file: %v", err)
|
||||
}
|
||||
|
||||
origRename := s.renameFileFn
|
||||
s.renameFileFn = func(oldPath, newPath string) error {
|
||||
if oldPath == absTarget+newFileSuffix && newPath == absTarget {
|
||||
return errors.New("forced rename failure")
|
||||
}
|
||||
return origRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
err = s.WriteFile(target, []byte("replacement"))
|
||||
if err == nil || !strings.Contains(err.Error(), "forced rename failure") {
|
||||
t.Fatalf("write error = %v, want forced rename failure", err)
|
||||
}
|
||||
|
||||
assertFileContent(t, s, target, "original")
|
||||
assertNoTempArtifacts(t, s, target)
|
||||
}
|
||||
|
||||
func TestSamePathOperationsSerialize(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := "shared.txt"
|
||||
absTarget, err := s.resolvePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
origWrite := s.writeFileFn
|
||||
var writes atomic.Int32
|
||||
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
||||
if path == absTarget+newFileSuffix && writes.Add(1) == 1 {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
return origWrite(path, data, perm)
|
||||
}
|
||||
|
||||
firstDone := make(chan error, 1)
|
||||
go func() {
|
||||
firstDone <- s.WriteFile(target, []byte("one"))
|
||||
}()
|
||||
waitSignal(t, entered, "first write entered")
|
||||
|
||||
secondDone := make(chan error, 1)
|
||||
go func() {
|
||||
secondDone <- s.WriteFile(target, []byte("two"))
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
t.Fatalf("second write finished before first released: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
if writes.Load() != 1 {
|
||||
t.Fatalf("same-path write reached file hook %d times before release, want 1", writes.Load())
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err := waitError(t, firstDone); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
if err := waitError(t, secondDone); err != nil {
|
||||
t.Fatalf("second write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentPathOperationsDoNotBlockEachOther(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
blockedTarget := "blocked.txt"
|
||||
absTarget, err := s.resolvePath(blockedTarget)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve blocked target: %v", err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
origWrite := s.writeFileFn
|
||||
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
||||
if path == absTarget+newFileSuffix {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
return origWrite(path, data, perm)
|
||||
}
|
||||
|
||||
blockedDone := make(chan error, 1)
|
||||
go func() {
|
||||
blockedDone <- s.WriteFile(blockedTarget, []byte("blocked"))
|
||||
}()
|
||||
waitSignal(t, entered, "blocked write entered")
|
||||
|
||||
freeDone := make(chan error, 1)
|
||||
go func() {
|
||||
freeDone <- s.WriteFile("free.txt", []byte("free"))
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-freeDone:
|
||||
if err != nil {
|
||||
t.Fatalf("free write: %v", err)
|
||||
}
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("write for a different path should not block")
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err := waitError(t, blockedDone); err != nil {
|
||||
t.Fatalf("blocked write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStateIsNonBlockingAndCallbackBased(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
origWrite := s.writeFileFn
|
||||
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
||||
close(entered)
|
||||
<-release
|
||||
return origWrite(path, data, perm)
|
||||
}
|
||||
|
||||
callbacks := make(chan error, 2)
|
||||
s.SaveState(sampleState(), func(err error) {
|
||||
callbacks <- err
|
||||
})
|
||||
|
||||
waitSignal(t, entered, "async save entered")
|
||||
|
||||
select {
|
||||
case err := <-callbacks:
|
||||
t.Fatalf("callback fired before storage write completed: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err := waitError(t, callbacks); err != nil {
|
||||
t.Fatalf("callback error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-callbacks:
|
||||
t.Fatalf("callback fired more than once: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func newTestStorage(t *testing.T) *fsStorage {
|
||||
t.Helper()
|
||||
|
||||
s, err := NewFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new test storage: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sampleState() client.State {
|
||||
return client.State{
|
||||
GameState: []client.GameState{
|
||||
{ID: client.GameID("game-1"), LastTurn: 12, ActiveTurn: 11},
|
||||
{ID: client.GameID("game-2"), LastTurn: 4, ActiveTurn: 4},
|
||||
},
|
||||
ActiveGameID: client.GameID("game-2"),
|
||||
}
|
||||
}
|
||||
|
||||
func sampleReport(turn uint, race string) report.Report {
|
||||
return report.Report{
|
||||
Turn: turn,
|
||||
Width: 160,
|
||||
Height: 90,
|
||||
PlanetCount: 8,
|
||||
Race: race,
|
||||
VoteFor: "assembly",
|
||||
}
|
||||
}
|
||||
|
||||
func sampleOrder() order.Order {
|
||||
return order.Order{
|
||||
UpdatedAt: 1700,
|
||||
Commands: []order.DecodableCommand{
|
||||
&order.CommandPlanetRename{
|
||||
CommandMeta: order.CommandMeta{
|
||||
CmdType: order.CommandTypePlanetRename,
|
||||
CmdID: "rename-planet",
|
||||
},
|
||||
Number: 2,
|
||||
Name: "Nova Prime",
|
||||
},
|
||||
&order.CommandRaceVote{
|
||||
CommandMeta: order.CommandMeta{
|
||||
CmdType: order.CommandTypeRaceVote,
|
||||
CmdID: "vote-race",
|
||||
},
|
||||
Acceptor: "ZENITH",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContent(t *testing.T, s *fsStorage, path, want string) {
|
||||
t.Helper()
|
||||
|
||||
got, err := s.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %q: %v", path, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("content for %q = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoTempArtifacts(t *testing.T, s *fsStorage, path string) {
|
||||
t.Helper()
|
||||
|
||||
absPath, err := s.resolvePath(path)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve path %q: %v", path, err)
|
||||
}
|
||||
for _, tempPath := range []string{absPath + newFileSuffix, absPath + oldFileSuffix} {
|
||||
if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("temp artifact %q should not exist, stat err = %v", tempPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitSignal(t *testing.T, ch <-chan struct{}, name string) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatalf("timeout waiting for %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func waitError(t *testing.T, ch <-chan error) error {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case err := <-ch:
|
||||
return err
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("timeout waiting for error callback")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitResult[T any](t *testing.T, ch <-chan callbackResult[T]) callbackResult[T] {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
return result
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("timeout waiting for callback result")
|
||||
return callbackResult[T]{}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user