aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2025-10-25 19:15:06 +0900
committerOphestra <cat@gensokyo.uk>2025-10-25 19:15:06 +0900
commit4f41afee0f6408f17d23460266e3d27998448985 (patch)
tree1374c940870d47b9e6953f64598ec6d0eab25767
parent7de593e81672b401fcaee075cdfaa8aba9e535d5 (diff)
internal/app/state: fixed size et-only header
This header improves the robustness of the format and significantly reduces cleanup overhead. Signed-off-by: Ophestra <cat@gensokyo.uk>
-rw-r--r--internal/app/state/header.go86
-rw-r--r--internal/app/state/header_test.go184
2 files changed, 270 insertions, 0 deletions
diff --git a/internal/app/state/header.go b/internal/app/state/header.go
new file mode 100644
index 00000000..25b1e6af
--- /dev/null
+++ b/internal/app/state/header.go
@@ -0,0 +1,86 @@
+package state
+
+import (
+ "encoding/hex"
+ "errors"
+ "io"
+ "os"
+ "strconv"
+ "syscall"
+
+ "hakurei.app/hst"
+)
+
+const (
+ // entryHeaderMagic are magic bytes at the beginning of the state entry file.
+ entryHeaderMagic = "\x00\xff\xca\xfe"
+ // entryHeaderRevision follows entryHeaderMagic and is incremented for revisions of the format.
+ entryHeaderRevision = "\x00\x00"
+ // entryHeaderSize is the fixed size of the header in bytes, including the enablement byte and its complement.
+ entryHeaderSize = len(entryHeaderMagic+entryHeaderRevision) + 2
+)
+
+// entryHeaderEncode encodes a state entry header for a [hst.Enablement] byte.
+func entryHeaderEncode(et hst.Enablement) *[entryHeaderSize]byte {
+ data := [entryHeaderSize]byte([]byte(
+ entryHeaderMagic + entryHeaderRevision + string([]hst.Enablement{et, ^et}),
+ ))
+ return &data
+}
+
+// entryHeaderDecode validates a state entry header and returns the [hst.Enablement] byte.
+func entryHeaderDecode(data *[entryHeaderSize]byte) (hst.Enablement, error) {
+ if magic := data[:len(entryHeaderMagic)]; string(magic) != entryHeaderMagic {
+ return 0, errors.New("invalid header " + hex.EncodeToString(magic))
+ }
+ if revision := data[len(entryHeaderMagic):len(entryHeaderMagic+entryHeaderRevision)]; string(revision) != entryHeaderRevision {
+ return 0, errors.New("unexpected revision " + hex.EncodeToString(revision))
+ }
+
+ et := data[len(entryHeaderMagic+entryHeaderRevision)]
+ if et != ^data[len(entryHeaderMagic+entryHeaderRevision)+1] {
+ return 0, errors.New("header enablement value is inconsistent")
+ }
+ return hst.Enablement(et), nil
+}
+
+// EntrySizeError is returned for a file too small to hold a state entry header.
+type EntrySizeError struct {
+ Name string
+ Size int64
+}
+
+func (e *EntrySizeError) Error() string {
+ if e.Name == "" {
+ return "state entry file is too short"
+ }
+ return "state entry file " + strconv.Quote(e.Name) + " is too short"
+}
+
+// entryCheckFile checks whether [os.FileInfo] refers to a file that might hold [hst.State].
+func entryCheckFile(fi os.FileInfo) error {
+ if fi.IsDir() {
+ return syscall.EISDIR
+ }
+ if s := fi.Size(); s <= int64(entryHeaderSize) {
+ return &EntrySizeError{Name: fi.Name(), Size: s}
+ }
+ return nil
+}
+
+// entryReadHeader reads [hst.Enablement] from an [io.Reader].
+func entryReadHeader(r io.Reader) (hst.Enablement, error) {
+ var data [entryHeaderSize]byte
+ if n, err := r.Read(data[:]); err != nil {
+ return 0, err
+ } else if n != entryHeaderSize {
+ return 0, &EntrySizeError{Size: int64(n)}
+ }
+ return entryHeaderDecode(&data)
+}
+
+// entryWriteHeader writes [hst.Enablement] header to an [io.Writer].
+func entryWriteHeader(w io.Writer, et hst.Enablement) error {
+ _, err := w.Write(entryHeaderEncode(et)[:])
+ return err
+}
diff --git a/internal/app/state/header_test.go b/internal/app/state/header_test.go
new file mode 100644
index 00000000..18a620c2
--- /dev/null
+++ b/internal/app/state/header_test.go
@@ -0,0 +1,184 @@
+package state
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+ "reflect"
+ "syscall"
+ "testing"
+ "time"
+
+ "hakurei.app/hst"
+)
+
+func TestEntryHeader(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ data [entryHeaderSize]byte
+ et hst.Enablement
+ err error
+ }{
+ {"complement mismatch", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00,
+ 0x0a, 0xf6}, 0,
+ errors.New("header enablement value is inconsistent")},
+ {"unexpected revision", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0xff, 0xff}, 0,
+ errors.New("unexpected revision ffff")},
+ {"invalid header", [entryHeaderSize]byte{0x00, 0xfe, 0xca, 0xfe}, 0,
+ errors.New("invalid header 00fecafe")},
+
+ {"success high", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00,
+ 0xff, 0x00}, 0xff, nil},
+ {"success", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00,
+ 0x09, 0xf6}, hst.EWayland | hst.EPulse, nil},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ t.Run("encode", func(t *testing.T) {
+ if tc.err != nil {
+ return
+ }
+ t.Parallel()
+
+ if got := entryHeaderEncode(tc.et); *got != tc.data {
+ t.Errorf("entryHeaderEncode: %x, want %x", *got, tc.data)
+ }
+
+ t.Run("write", func(t *testing.T) {
+ var buf bytes.Buffer
+ if err := entryWriteHeader(&buf, tc.et); err != nil {
+ t.Fatalf("entryWriteHeader: error = %v", err)
+ }
+ if got := ([entryHeaderSize]byte)(buf.Bytes()); got != tc.data {
+ t.Errorf("entryWriteHeader: %x, want %x", got, tc.data)
+ }
+ })
+ })
+
+ t.Run("decode", func(t *testing.T) {
+ t.Parallel()
+
+ got, err := entryHeaderDecode(&tc.data)
+ if !reflect.DeepEqual(err, tc.err) {
+ t.Fatalf("entryHeaderDecode: error = %#v, want %#v", err, tc.err)
+ }
+ if err != nil {
+ return
+ }
+ if got != tc.et {
+ t.Errorf("entryHeaderDecode: et = %q, want %q", got, tc.et)
+ }
+
+ if got, err = entryReadHeader(bytes.NewReader(tc.data[:])); err != nil {
+ t.Fatalf("entryReadHeader: error = %#v", err)
+ } else if got != tc.et {
+ t.Errorf("entryReadHeader: et = %q, want %q", got, tc.et)
+ }
+ })
+ })
+ }
+}
+
+func TestEntrySizeError(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ err error
+ want string
+ }{
+ {"size only", &EntrySizeError{Size: 0xdeadbeef},
+ `state entry file is too short`},
+ {"full", &EntrySizeError{Name: "nonexistent", Size: 0xdeadbeef},
+ `state entry file "nonexistent" is too short`},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ if got := tc.err.Error(); got != tc.want {
+ t.Errorf("Error: %s, want %s", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestEntryCheckFile(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ fi os.FileInfo
+ err error
+ }{
+ {"dir", &stubFi{name: "dir", isDir: true},
+ syscall.EISDIR},
+ {"short", stubFi{name: "short", size: 8},
+ &EntrySizeError{Name: "short", Size: 8}},
+ {"success", stubFi{size: 9}, nil},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ if err := entryCheckFile(tc.fi); !reflect.DeepEqual(err, tc.err) {
+ t.Errorf("entryCheckFile: error = %#v, want %#v", err, tc.err)
+ }
+ })
+ }
+}
+
+func TestEntryReadHeader(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ newR func() io.Reader
+ err error
+ }{
+ {"eof", func() io.Reader { return bytes.NewReader([]byte{}) }, io.EOF},
+ {"short", func() io.Reader { return bytes.NewReader([]byte{0}) }, &EntrySizeError{Size: 1}},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ if _, err := entryReadHeader(tc.newR()); !reflect.DeepEqual(err, tc.err) {
+ t.Errorf("entryReadHeader: error = %#v, want %#v", err, tc.err)
+ }
+ })
+ }
+}
+
+// stubFi partially implements [os.FileInfo] using hardcoded values.
+type stubFi struct {
+ name string
+ size int64
+ isDir bool
+}
+
+func (fi stubFi) Name() string {
+ if fi.name == "" {
+ panic("unreachable")
+ }
+ return fi.name
+}
+
+func (fi stubFi) Size() int64 {
+ if fi.size < 0 {
+ panic("unreachable")
+ }
+ return fi.size
+}
+
+func (fi stubFi) IsDir() bool { return fi.isDir }
+
+func (fi stubFi) Mode() fs.FileMode { panic("unreachable") }
+func (fi stubFi) ModTime() time.Time { panic("unreachable") }
+func (fi stubFi) Sys() any { panic("unreachable") }