summaryrefslogtreecommitdiff
path: root/server/internal/signature/persist.go
blob: d72839d8fb132073cefb59bcfee01abd2e2089f2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
package signature

import (
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
)

// MarshalJSON writes a fingerprint as two base64 blobs rather than two JSON arrays.
//
// Size is the whole reason. A 37-second signature is about three thousand hashes, and as JSON
// numbers that is 30KB each -- a few hundred signatures would be a file nobody wants to parse on
// every start. Packed little-endian and base64'd it is a quarter of that.
func (f Fingerprint) MarshalJSON() ([]byte, error) {
	return json.Marshal(struct {
		Hashes string `json:"hashes"`
		Frames string `json:"frames"`
	}{
		Hashes: encodeUint32(f.Hashes),
		Frames: encodeInt32(f.Frames),
	})
}

// UnmarshalJSON reads what MarshalJSON wrote.
func (f *Fingerprint) UnmarshalJSON(raw []byte) error {
	var packed struct {
		Hashes string `json:"hashes"`
		Frames string `json:"frames"`
	}
	if err := json.Unmarshal(raw, &packed); err != nil {
		return err
	}
	hashes, err := decodeUint32(packed.Hashes)
	if err != nil {
		return fmt.Errorf("fingerprint hashes: %w", err)
	}
	frames, err := decodeInt32(packed.Frames)
	if err != nil {
		return fmt.Errorf("fingerprint frames: %w", err)
	}
	if len(hashes) != len(frames) {
		return fmt.Errorf("fingerprint: %d hashes but %d frames", len(hashes), len(frames))
	}
	f.Hashes, f.Frames = hashes, frames
	return nil
}

func encodeUint32(vs []uint32) string {
	buf := make([]byte, 4*len(vs))
	for i, v := range vs {
		binary.LittleEndian.PutUint32(buf[4*i:], v)
	}
	return base64.StdEncoding.EncodeToString(buf)
}

func encodeInt32(vs []int32) string {
	buf := make([]byte, 4*len(vs))
	for i, v := range vs {
		binary.LittleEndian.PutUint32(buf[4*i:], uint32(v))
	}
	return base64.StdEncoding.EncodeToString(buf)
}

func decodeUint32(s string) ([]uint32, error) {
	raw, err := base64.StdEncoding.DecodeString(s)
	if err != nil {
		return nil, err
	}
	if len(raw)%4 != 0 {
		return nil, fmt.Errorf("%d bytes is not whole 32-bit values", len(raw))
	}
	out := make([]uint32, len(raw)/4)
	for i := range out {
		out[i] = binary.LittleEndian.Uint32(raw[4*i:])
	}
	return out, nil
}

func decodeInt32(s string) ([]int32, error) {
	vs, err := decodeUint32(s)
	if err != nil {
		return nil, err
	}
	out := make([]int32, len(vs))
	for i, v := range vs {
		out[i] = int32(v)
	}
	return out, nil
}

type persistedLibrary struct {
	Entries []*Entry `json:"entries"`
}

// Load reads a library from disk. A missing file is a first run, not an error.
func Load(path string) (*Library, error) {
	raw, err := os.ReadFile(path)
	if os.IsNotExist(err) {
		return NewLibrary(), nil
	}
	if err != nil {
		return nil, fmt.Errorf("load signatures: %w", err)
	}
	var p persistedLibrary
	if err := json.Unmarshal(raw, &p); err != nil {
		return nil, fmt.Errorf("load signatures: %w", err)
	}
	l := NewLibrary()
	l.entries = p.Entries
	// The index is derived and is not stored. Rebuilding it here is not an optimisation: skip
	// it and every signature silently stops matching the moment the server restarts.
	l.reindex()
	return l, nil
}

// Save writes the library atomically, via a temporary file and a rename.
//
// The same reasoning as the state file: this is months of accumulated evidence that cannot be
// recomputed from anything still on disk, and a truncated write would lose it.
func (l *Library) Save(path string) error {
	raw, err := json.Marshal(persistedLibrary{Entries: l.entries})
	if err != nil {
		return fmt.Errorf("save signatures: %w", err)
	}
	tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*")
	if err != nil {
		return fmt.Errorf("save signatures: %w", err)
	}
	name := tmp.Name()
	if _, err := tmp.Write(raw); err != nil {
		tmp.Close()
		os.Remove(name)
		return fmt.Errorf("save signatures: %w", err)
	}
	if err := tmp.Close(); err != nil {
		os.Remove(name)
		return fmt.Errorf("save signatures: %w", err)
	}
	if err := os.Chmod(name, 0o600); err != nil {
		os.Remove(name)
		return fmt.Errorf("save signatures: %w", err)
	}
	if err := os.Rename(name, path); err != nil {
		os.Remove(name)
		return fmt.Errorf("save signatures: %w", err)
	}
	return nil
}