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 }