package fs import ( "encoding" "errors" "fmt" "galaxy/util" "os" "path/filepath" ) const defaultPerm = 0o644 // FS is the file-backed Storage implementation: atomic, lock-free reads and // writes rooted at a single per-game directory. type FS struct { root string } func NewFileStorage(path string) (*FS, error) { absPath, err := filepath.Abs(path) if err != nil { return nil, fmt.Errorf("path %s invalid: %s", path, err) } if ok, err := util.DirExists(absPath); err != nil { return nil, fmt.Errorf("check dir exist: %s", err) } else if !ok { return nil, errors.New("directory does not exist: " + absPath) } if ok, err := util.Writable(absPath); err != nil { return nil, fmt.Errorf("check dir access: %s", err) } else if !ok { return nil, errors.New("directory should have read-write access: " + absPath) } return &FS{root: path}, nil } func (f *FS) Exists(path string) (bool, error) { return util.FileExists(filepath.Join(f.root, path)) } // Write atomically persists v at path: it stages the payload in a temporary // file and swaps it into place with a single rename. On POSIX rename replaces // the destination atomically, so a concurrent reader always observes either // the previous file or the new one in full — the target is never absent // mid-write and never half-written. This atomic replace is the only // protection against torn reads; the storage holds no lock, and concurrent // writers to the same state file are serialised one layer up, at the router. func (f *FS) Write(path string, v encoding.BinaryMarshaler) error { if v == nil { return errors.New("cant't marshal from nil object") } targetFilePath := filepath.Join(f.root, path) data, err := v.MarshalBinary() if err != nil { return fmt.Errorf("marshal data: %s", err) } targetDir := filepath.Dir(targetFilePath) if targetDir != f.root { ok, err := util.DirExists(targetDir) if err != nil { return fmt.Errorf("check target dir exists: %s", err) } if !ok { if err := os.MkdirAll(targetDir, os.ModePerm); err != nil { return fmt.Errorf("create target dirs: %s", err) } } } // Stage the payload in a uniquely named temporary file next to the target // and swap it in with a single rename. A unique temp name means a crashed // write leaves no fixed-name leftover that would block later writes, and a // single rename is the atomic replace POSIX guarantees. tmp, err := os.CreateTemp(targetDir, filepath.Base(targetFilePath)+".tmp-*") if err != nil { return fmt.Errorf("create temp file: %s", err) } tmpPath := tmp.Name() if _, err := tmp.Write(data); err != nil { tmp.Close() os.Remove(tmpPath) return fmt.Errorf("write temp file: %s", err) } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return fmt.Errorf("close temp file: %s", err) } if err := os.Chmod(tmpPath, defaultPerm); err != nil { os.Remove(tmpPath) return fmt.Errorf("chmod temp file: %s", err) } if err := os.Rename(tmpPath, targetFilePath); err != nil { os.Remove(tmpPath) return fmt.Errorf("replace target file: %s", err) } return nil } // Read loads path into v. Reads need no lock: because Write swaps files into // place atomically with rename, a reader always observes a complete file even // when a write is in flight. func (f *FS) Read(path string, v encoding.BinaryUnmarshaler) error { if v == nil { return errors.New("can't unmarshal to a nil object") } data, err := os.ReadFile(filepath.Join(f.root, path)) if err != nil { return fmt.Errorf("reading data file: %s", err) } return v.UnmarshalBinary(data) }