// Package utils provides shared functionality for dumping CT log tile entries. // (C) Copyright 2026 Pim van Pelt package utils import ( "bytes" "compress/gzip" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "filippo.io/sunlight" ) const maxCompressRatio = 100 // Entry represents a CT log entry in JSON format. type Entry struct { EntryNumber int `json:"entry_number"` LeafIndex int64 `json:"leaf_index"` Timestamp int64 `json:"timestamp"` IsPrecert bool `json:"is_precert"` IssuerKeyHash string `json:"issuer_key_hash,omitempty"` CertificateSize int `json:"certificate_size"` PreCertificateSize *int `json:"precertificate_size,omitempty"` ChainFingerprints []string `json:"chain_fingerprints"` ParsedCertInfo json.RawMessage `json:"parsed_cert_info,omitempty"` } // HashTileOutput represents a hash tile in JSON format. type HashTileOutput struct { NumHashes int `json:"num_hashes"` Hashes []string `json:"hashes"` } // DumpResult is the result of dumping entries or hashes from a tile. type DumpResult struct { Entries []Entry `json:"entries,omitempty"` HashTile *HashTileOutput `json:"hash_tile,omitempty"` TotalEntries int `json:"total_entries,omitempty"` } // FetchURL fetches data from a URL. func FetchURL(url string) ([]byte, error) { resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } return io.ReadAll(resp.Body) } // Decompress decompresses gzip-compressed data, or returns the data as-is if not compressed. func Decompress(data []byte) ([]byte, error) { r, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { // Not gzipped, return as-is return data, nil } maxSize := int64(len(data)) * maxCompressRatio return io.ReadAll(io.LimitReader(r, maxSize)) } // DumpAllEntries reads and returns all entries from tile data as JSON-serializable structures. // Automatically detects if the tile is a data tile or hash tile. func DumpAllEntries(tileData []byte) (*DumpResult, error) { // Try to read as data tile first result, err := dumpDataTile(tileData) if err != nil { // If it fails, try as hash tile fmt.Fprintf(os.Stderr, "Not a data tile, trying as hash tile...\n") return dumpHashTile(tileData) } return result, nil } func dumpDataTile(tileData []byte) (*DumpResult, error) { entryNum := 0 var entries []Entry for len(tileData) > 0 { e, remaining, err := sunlight.ReadTileLeaf(tileData) if err != nil { return nil, fmt.Errorf("failed to read entry %d: %w", entryNum, err) } tileData = remaining entry := convertEntry(e, entryNum) entries = append(entries, entry) entryNum++ } return &DumpResult{ Entries: entries, TotalEntries: entryNum, }, nil } func dumpHashTile(tileData []byte) (*DumpResult, error) { const hashSize = 32 // SHA-256 hash size if len(tileData)%hashSize != 0 { return nil, fmt.Errorf("invalid hash tile: size %d is not a multiple of %d", len(tileData), hashSize) } numHashes := len(tileData) / hashSize hashes := make([]string, numHashes) for i := 0; i < numHashes; i++ { hash := tileData[i*hashSize : (i+1)*hashSize] hashes[i] = hex.EncodeToString(hash) } return &DumpResult{ HashTile: &HashTileOutput{ NumHashes: numHashes, Hashes: hashes, }, }, nil } // DumpEntryAtPosition reads and returns a specific entry at the given position. func DumpEntryAtPosition(tileData []byte, position int, expectedIndex int64) (*Entry, error) { entryNum := 0 for len(tileData) > 0 { e, remaining, err := sunlight.ReadTileLeaf(tileData) if err != nil { return nil, fmt.Errorf("failed to read entry %d: %w", entryNum, err) } tileData = remaining if entryNum == position { if e.LeafIndex != expectedIndex { fmt.Fprintf(os.Stderr, "WARNING: Expected leaf index %d but found %d at position %d\n", expectedIndex, e.LeafIndex, position) } entry := convertEntry(e, entryNum) return &entry, nil } entryNum++ } return nil, fmt.Errorf("position %d not found in tile (only %d entries)", position, entryNum) } func convertEntry(e *sunlight.LogEntry, entryNum int) Entry { entry := Entry{ EntryNumber: entryNum, LeafIndex: e.LeafIndex, Timestamp: e.Timestamp, IsPrecert: e.IsPrecert, CertificateSize: len(e.Certificate), } if e.IsPrecert { entry.IssuerKeyHash = hex.EncodeToString(e.IssuerKeyHash[:]) } if e.PreCertificate != nil { size := len(e.PreCertificate) entry.PreCertificateSize = &size } // Convert chain fingerprints to hex strings entry.ChainFingerprints = make([]string, len(e.ChainFingerprints)) for i, fp := range e.ChainFingerprints { entry.ChainFingerprints[i] = hex.EncodeToString(fp[:]) } // Try to extract parsed certificate info if trimmed, err := e.TrimmedEntry(); err == nil { if data, err := json.Marshal(trimmed); err == nil { entry.ParsedCertInfo = data } } return entry }