From 4f41afee0f6408f17d23460266e3d27998448985 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Sat, 25 Oct 2025 19:15:06 +0900 Subject: [PATCH] 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 --- internal/app/state/header.go | 86 ++++++++++++++ internal/app/state/header_test.go | 184 ++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 internal/app/state/header.go create mode 100644 internal/app/state/header_test.go diff --git a/internal/app/state/header.go b/internal/app/state/header.go new file mode 100644 index 0000000..25b1e6a --- /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 0000000..18a620c --- /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") }