// Package fs implements galaxy/storage.Storage using the filesystem. package fs import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "slices" "strconv" "strings" "sync" "galaxy/model/client" "galaxy/model/order" "galaxy/model/report" "galaxy/util" ) const ( // stateFileName is the file name under the storage root where [client.State] is stored. stateFileName = "state.dat" // gameDataFileSuffix is the extension for per-turn [client.GameData] files. gameDataFileSuffix = ".dat" defaultFilePerm = 0o644 oldFileSuffix = ".old" newFileSuffix = ".new" ) // StateFilePath returns the path to the persisted [client.State] file under root. func StateFilePath(root string) string { return filepath.Join(root, stateFileName) } // GameDataFilePath returns the legacy per-game data file path under root. // // The storage implementation keeps turn data in per-turn files under a game directory // and does not use this helper internally. func GameDataFilePath(root string, id fmt.Stringer) string { return filepath.Join(root, id.String()) + gameDataFileSuffix } type pathLock struct { mu sync.Mutex refs int } type fsStorage struct { storageRoot string locksMu sync.Mutex locks map[string]*pathLock readFileFn func(string) ([]byte, error) writeFileFn func(string, []byte, os.FileMode) error renameFileFn func(string, string) error removeFileFn func(string) error } type storedOrder struct { UpdatedAt int `json:"updatedAt"` Commands []json.RawMessage `json:"cmd"` } type storedGameData struct { Turn uint `json:"turn"` Report report.Report `json:"report"` Order *storedOrder `json:"order,omitempty"` } // NewFS returns a filesystem-backed implementation of galaxy/storage.Storage rooted at storageRoot. // storageRoot must already exist, be a directory, and be writable by the current user. func NewFS(storageRoot string) (*fsStorage, error) { fmt.Println("using fs root:", storageRoot) absRoot, err := filepath.Abs(storageRoot) if err != nil { return nil, fmt.Errorf("new fs storage: resolve absolute path for %q: %w", storageRoot, err) } if ok, err := util.PathExists(absRoot, true); err != nil { return nil, fmt.Errorf("new fs storage: check path %q exists: %w", absRoot, err) } else if !ok { return nil, fmt.Errorf("new fs storage: path %q does not exist", absRoot) } if ok, err := util.Writable(absRoot); err != nil { return nil, fmt.Errorf("new fs storage: check path %q writable: %w", absRoot, err) } else if !ok { return nil, fmt.Errorf("new fs storage: path %q is not writable", absRoot) } return &fsStorage{ storageRoot: absRoot, locks: make(map[string]*pathLock), readFileFn: os.ReadFile, writeFileFn: os.WriteFile, renameFileFn: os.Rename, removeFileFn: os.Remove, }, nil } func (s *fsStorage) StateExistsAsync(callback func(bool, error)) { go func() { exists, err := s.StateExists() if callback != nil { callback(exists, err) } }() } func (s *fsStorage) LoadStateAsync(callback func(client.State, error)) { go func() { state, err := s.LoadState() if callback != nil { callback(state, err) } }() } func (s *fsStorage) SaveStateAsync(state client.State, callback func(error)) { go func() { err := s.SaveState(state) if callback != nil { callback(err) } }() } func (s *fsStorage) LoadReportAsync(id client.GameID, turn uint, callback func(report.Report, error)) { go func() { rep, err := s.loadReportSync(id, turn) if callback != nil { callback(rep, err) } }() } func (s *fsStorage) SaveReportAsync(id client.GameID, turn uint, rep report.Report, callback func(error)) { go func() { err := s.saveReportSync(id, turn, rep) if callback != nil { callback(err) } }() } func (s *fsStorage) LoadOrderAsync(id client.GameID, turn uint, callback func(order.Order, error)) { go func() { o, err := s.loadOrderSync(id, turn) if callback != nil { callback(o, err) } }() } func (s *fsStorage) SaveOrderAsync(id client.GameID, turn uint, o order.Order, callback func(error)) { go func() { err := s.saveOrderSync(id, turn, o) if callback != nil { callback(err) } }() } func (s *fsStorage) FileExists(path string) (bool, string, error) { absPath, err := s.resolvePath(path) if err != nil { return false, "", err } var exists bool err = s.withPathLock(absPath, func() error { var opErr error exists, opErr = s.fileExistsUnlocked(absPath) return opErr }) if err != nil { return false, "", err } if !exists { return false, "", nil } return true, absPath, nil } func (s *fsStorage) ReadFile(path string) ([]byte, error) { absPath, err := s.resolvePath(path) if err != nil { return nil, err } var data []byte err = s.withPathLock(absPath, func() error { var opErr error data, opErr = s.readFileUnlocked(absPath) return opErr }) return data, err } func (s *fsStorage) WriteFile(path string, data []byte) error { absPath, err := s.resolvePath(path) if err != nil { return err } return s.withPathLock(absPath, func() error { return s.writeFileUnlocked(absPath, data) }) } func (s *fsStorage) DeleteFile(path string) error { absPath, err := s.resolvePath(path) if err != nil { return err } return s.withPathLock(absPath, func() error { exists, err := s.fileExistsUnlocked(absPath) if err != nil { return err } if !exists { return fmt.Errorf("delete file %q: %w", absPath, os.ErrNotExist) } if err := s.removeFileFn(absPath); err != nil { return fmt.Errorf("delete file %q: %w", absPath, err) } return nil }) } func (s *fsStorage) ListFiles() ([]string, error) { files := make([]string, 0) err := filepath.WalkDir(s.storageRoot, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() { return nil } if strings.HasSuffix(d.Name(), newFileSuffix) || strings.HasSuffix(d.Name(), oldFileSuffix) { return nil } relPath, err := filepath.Rel(s.storageRoot, path) if err != nil { return fmt.Errorf("resolve relative path for %q: %w", path, err) } files = append(files, filepath.Clean(relPath)) return nil }) if err != nil { return nil, fmt.Errorf("list files under %q: %w", s.storageRoot, err) } slices.Sort(files) return files, nil } func (s *fsStorage) StateExists() (bool, error) { exists, _, err := s.FileExists(stateFileName) return exists, err } func (s *fsStorage) LoadState() (client.State, error) { data, err := s.ReadFile(stateFileName) if err != nil { return client.State{}, err } return unmarshalState(data) } func (s *fsStorage) SaveState(state client.State) error { data, err := marshalState(state) if err != nil { return err } return s.WriteFile(stateFileName, data) } func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report, error) { gameData, err := s.loadGameDataSync(id, turn) if err != nil { return report.Report{}, err } return gameData.Report, nil } func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Report) error { absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) if err != nil { return err } return s.withPathLock(absPath, func() error { gameData, err := s.loadGameDataUnlocked(absPath) if err != nil { if !errors.Is(err, os.ErrNotExist) { return err } gameData = client.GameData{Turn: turn} } gameData.Turn = turn gameData.Report = rep return s.writeGameDataUnlocked(absPath, gameData) }) } func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, error) { gameData, err := s.loadGameDataSync(id, turn) if err != nil { return order.Order{}, err } if gameData.Order == nil { return order.Order{}, fmt.Errorf("load order for game %q turn %d: %w", id, turn, os.ErrNotExist) } return *gameData.Order, nil } func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) error { absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) if err != nil { return err } return s.withPathLock(absPath, func() error { gameData, err := s.loadGameDataUnlocked(absPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("save order for game %q turn %d: %w", id, turn, os.ErrNotExist) } return err } gameData.Turn = turn gameData.Order = &o return s.writeGameDataUnlocked(absPath, gameData) }) } func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) { absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) if err != nil { return client.GameData{}, err } var gameData client.GameData err = s.withPathLock(absPath, func() error { var opErr error gameData, opErr = s.loadGameDataUnlocked(absPath) return opErr }) return gameData, err } func (s *fsStorage) loadGameDataUnlocked(absPath string) (client.GameData, error) { data, err := s.readFileUnlocked(absPath) if err != nil { return client.GameData{}, err } return unmarshalGameData(data) } func (s *fsStorage) writeGameDataUnlocked(absPath string, gameData client.GameData) error { data, err := marshalGameData(gameData) if err != nil { return err } return s.writeFileUnlocked(absPath, data) } func marshalState(state client.State) ([]byte, error) { return marshalJSON(state) } func unmarshalState(data []byte) (client.State, error) { var state client.State if err := unmarshalJSON(data, &state); err != nil { return client.State{}, err } return state, nil } func marshalGameData(gameData client.GameData) ([]byte, error) { stored, err := makeStoredGameData(gameData) if err != nil { return nil, err } return marshalJSON(stored) } func unmarshalGameData(data []byte) (client.GameData, error) { var stored storedGameData if err := unmarshalJSON(data, &stored); err != nil { return client.GameData{}, err } return stored.toGameData() } func marshalJSON(value any) ([]byte, error) { data, err := json.Marshal(value) if err != nil { return nil, fmt.Errorf("marshal json: %w", err) } return data, nil } func unmarshalJSON(data []byte, target any) error { if err := json.Unmarshal(data, target); err != nil { return fmt.Errorf("unmarshal json: %w", err) } return nil } func makeStoredGameData(gameData client.GameData) (storedGameData, error) { stored := storedGameData{ Turn: gameData.Turn, Report: gameData.Report, } if gameData.Order == nil { return stored, nil } storedOrder, err := makeStoredOrder(*gameData.Order) if err != nil { return storedGameData{}, err } stored.Order = &storedOrder return stored, nil } func (d storedGameData) toGameData() (client.GameData, error) { gameData := client.GameData{ Turn: d.Turn, Report: d.Report, } if d.Order == nil { return gameData, nil } o, err := d.Order.toOrder() if err != nil { return client.GameData{}, err } gameData.Order = o return gameData, nil } func makeStoredOrder(o order.Order) (storedOrder, error) { result := storedOrder{ UpdatedAt: o.UpdatedAt, Commands: make([]json.RawMessage, len(o.Commands)), } for i := range o.Commands { data, err := marshalJSON(o.Commands[i]) if err != nil { return storedOrder{}, fmt.Errorf("marshal order command %d: %w", i, err) } result.Commands[i] = data } return result, nil } func (o *storedOrder) toOrder() (*order.Order, error) { if o == nil { return nil, nil } result := &order.Order{ UpdatedAt: o.UpdatedAt, Commands: make([]order.DecodableCommand, len(o.Commands)), } for i := range o.Commands { cmd, err := parseOrderCommand(o.Commands[i]) if err != nil { return nil, fmt.Errorf("decode order command %d: %w", i, err) } result.Commands[i] = cmd } return result, nil } func parseOrderCommand(data json.RawMessage) (order.DecodableCommand, error) { meta := new(order.CommandMeta) if err := json.Unmarshal(data, meta); err != nil { return nil, fmt.Errorf("decode order command metadata: %w", err) } switch meta.CmdType { case order.CommandTypeRaceQuit: return decodeOrderCommand(data, new(order.CommandRaceQuit)) case order.CommandTypeRaceVote: return decodeOrderCommand(data, new(order.CommandRaceVote)) case order.CommandTypeRaceRelation: return decodeOrderCommand(data, new(order.CommandRaceRelation)) case order.CommandTypeShipClassCreate: return decodeOrderCommand(data, new(order.CommandShipClassCreate)) case order.CommandTypeShipClassMerge: return decodeOrderCommand(data, new(order.CommandShipClassMerge)) case order.CommandTypeShipClassRemove: return decodeOrderCommand(data, new(order.CommandShipClassRemove)) case order.CommandTypeShipGroupBreak: return decodeOrderCommand(data, new(order.CommandShipGroupBreak)) case order.CommandTypeShipGroupLoad: return decodeOrderCommand(data, new(order.CommandShipGroupLoad)) case order.CommandTypeShipGroupUnload: return decodeOrderCommand(data, new(order.CommandShipGroupUnload)) case order.CommandTypeShipGroupSend: return decodeOrderCommand(data, new(order.CommandShipGroupSend)) case order.CommandTypeShipGroupUpgrade: return decodeOrderCommand(data, new(order.CommandShipGroupUpgrade)) case order.CommandTypeShipGroupMerge: return decodeOrderCommand(data, new(order.CommandShipGroupMerge)) case order.CommandTypeShipGroupDismantle: return decodeOrderCommand(data, new(order.CommandShipGroupDismantle)) case order.CommandTypeShipGroupTransfer: return decodeOrderCommand(data, new(order.CommandShipGroupTransfer)) case order.CommandTypeShipGroupJoinFleet: return decodeOrderCommand(data, new(order.CommandShipGroupJoinFleet)) case order.CommandTypeFleetMerge: return decodeOrderCommand(data, new(order.CommandFleetMerge)) case order.CommandTypeFleetSend: return decodeOrderCommand(data, new(order.CommandFleetSend)) case order.CommandTypeScienceCreate: return decodeOrderCommand(data, new(order.CommandScienceCreate)) case order.CommandTypeScienceRemove: return decodeOrderCommand(data, new(order.CommandScienceRemove)) case order.CommandTypePlanetRename: return decodeOrderCommand(data, new(order.CommandPlanetRename)) case order.CommandTypePlanetProduce: return decodeOrderCommand(data, new(order.CommandPlanetProduce)) case order.CommandTypePlanetRouteSet: return decodeOrderCommand(data, new(order.CommandPlanetRouteSet)) case order.CommandTypePlanetRouteRemove: return decodeOrderCommand(data, new(order.CommandPlanetRouteRemove)) default: return nil, fmt.Errorf("unknown order command type %q", meta.CmdType) } } func decodeOrderCommand[T order.DecodableCommand](data json.RawMessage, target T) (T, error) { if err := json.Unmarshal(data, target); err != nil { return target, err } return target, nil } func (s *fsStorage) resolvePath(path string) (string, error) { relPath, err := normalizeRelativePath(path) if err != nil { return "", err } return filepath.Join(s.storageRoot, relPath), nil } func normalizeRelativePath(path string) (string, error) { path = strings.ReplaceAll(path, "\\", string(filepath.Separator)) path = strings.TrimLeft(path, string(filepath.Separator)) path = filepath.Clean(path) switch { case path == "." || path == "": return "", errors.New("path must not be empty") case filepath.IsAbs(path): return "", fmt.Errorf("path %q must be relative", path) case filepath.VolumeName(path) != "": return "", fmt.Errorf("path %q must not include a volume name", path) case path == "..": return "", fmt.Errorf("path %q escapes storage root", path) case strings.HasPrefix(path, ".."+string(filepath.Separator)): return "", fmt.Errorf("path %q escapes storage root", path) } return path, nil } func gameTurnFilePath(id fmt.Stringer, turn uint) string { return filepath.Join(id.String(), strconv.FormatUint(uint64(turn), 10)+gameDataFileSuffix) } func (s *fsStorage) withPathLock(absPath string, fn func() error) error { lock := s.acquirePathLock(absPath) defer s.releasePathLock(absPath) lock.mu.Lock() defer lock.mu.Unlock() return fn() } func (s *fsStorage) acquirePathLock(absPath string) *pathLock { s.locksMu.Lock() defer s.locksMu.Unlock() lock, ok := s.locks[absPath] if !ok { lock = &pathLock{} s.locks[absPath] = lock } lock.refs++ return lock } func (s *fsStorage) releasePathLock(absPath string) { s.locksMu.Lock() defer s.locksMu.Unlock() lock, ok := s.locks[absPath] if !ok { return } lock.refs-- if lock.refs == 0 { delete(s.locks, absPath) } } func (s *fsStorage) fileExistsUnlocked(absPath string) (bool, error) { ok, err := util.FileExists(absPath) if err != nil { return false, fmt.Errorf("check file %q exists: %w", absPath, err) } return ok, nil } func (s *fsStorage) readFileUnlocked(absPath string) ([]byte, error) { data, err := s.readFileFn(absPath) if err != nil { return nil, fmt.Errorf("read file %q: %w", absPath, err) } return data, nil } func (s *fsStorage) writeFileUnlocked(absPath string, data []byte) error { if err := s.ensureParentDir(absPath); err != nil { return err } targetExists, err := s.fileExistsUnlocked(absPath) if err != nil { return err } oldPath := absPath + oldFileSuffix oldExists, err := s.fileExistsUnlocked(oldPath) if err != nil { return err } if oldExists { return fmt.Errorf("write file %q: old file already exists at %q", absPath, oldPath) } newPath := absPath + newFileSuffix newExists, err := s.fileExistsUnlocked(newPath) if err != nil { return err } if newExists { return fmt.Errorf("write file %q: new file already exists at %q", absPath, newPath) } if err := s.writeFileFn(newPath, data, defaultFilePerm); err != nil { return fmt.Errorf("write new file %q: %w", newPath, err) } if targetExists { if err := s.renameFileFn(absPath, oldPath); err != nil { return errors.Join( fmt.Errorf("rename file %q to %q: %w", absPath, oldPath, err), s.cleanupTempFile(newPath), ) } } if err := s.renameFileFn(newPath, absPath); err != nil { var restoreErr error if targetExists { restoreErr = s.renameFileFn(oldPath, absPath) if restoreErr != nil { restoreErr = fmt.Errorf("restore file %q from %q: %w", absPath, oldPath, restoreErr) } } return errors.Join( fmt.Errorf("rename new file %q to %q: %w", newPath, absPath, err), restoreErr, s.cleanupTempFile(newPath), ) } if !targetExists { return nil } if err := s.removeFileFn(oldPath); err != nil { return fmt.Errorf("remove old file %q: %w", oldPath, err) } return nil } func (s *fsStorage) cleanupTempFile(path string) error { if err := s.removeFileFn(path); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("remove temp file %q: %w", path, err) } return nil } func (s *fsStorage) ensureParentDir(absPath string) error { parentDir := filepath.Dir(absPath) if err := os.MkdirAll(parentDir, os.ModePerm); err != nil { return fmt.Errorf("create parent directory %q: %w", parentDir, err) } return nil }